diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index d5bead3..aaf7e6c 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -44,6 +44,12 @@ jobs: - name: Verify published artifact boundaries run: bash scripts/verify-artifacts.sh + - name: Execute packaged SDK consumer + run: bash scripts/test-runtime-minimum.sh ${{ matrix.java }} + + - name: Check README initialization + run: bash scripts/test-readme-initialization.sh + example: name: Featurevisor example-1 runs-on: ubuntu-latest @@ -72,7 +78,7 @@ jobs: run: | mkdir example-1 cd example-1 - npx --yes @featurevisor/cli@3 init --example=1 + npx --yes @featurevisor/cli@latest init --example=1 npm install npx featurevisor build npx featurevisor test --onlyFailures diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ecc9113..f3b5ff3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,23 +36,8 @@ jobs: id: version shell: bash run: | - if [[ ! "$GITHUB_REF_NAME" =~ ^v4\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then - echo "Expected a Featurevisor Java v4 semantic version tag" >&2 - exit 1 - fi + bash scripts/verify-release.sh "$GITHUB_REF_NAME" version="${GITHUB_REF_NAME#v}" - if ! grep -Fq "$version" pom.xml; then - echo "Tag $GITHUB_REF_NAME does not match the Maven revision" >&2 - exit 1 - fi - if ! grep -Fq "$version" README.md; then - echo "README installation version does not match $GITHUB_REF_NAME" >&2 - exit 1 - fi - if ! grep -Fq "version = \"$version\"" featurevisor-sdk/src/main/java/com/featurevisor/cli/CLI.java; then - echo "CLI version does not match $GITHUB_REF_NAME" >&2 - exit 1 - fi echo "version=$version" >> "$GITHUB_OUTPUT" echo "Publishing Featurevisor Java $version" diff --git a/.nvmrc b/.nvmrc index 209e3ef..a45fd52 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20 +24 diff --git a/README.md b/README.md index a12058b..a8589d0 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,16 @@ # Featurevisor Java SDK -This is a port of Featurevisor [Javascript SDK](https://featurevisor.com/docs/sdks/javascript/) v3.x to Java, providing a way to evaluate feature flags, variations, and variables in your Java applications. - -This SDK supports Featurevisor v3 behaviour and v2 datafiles. Generated datafiles continue to carry `schemaVersion: "2"`. +Use the Featurevisor Java SDK to evaluate flags, variations and variables in Java applications. ## Table of contents - [Installation](#installation) -- [Public API](#public-api) - [Repository](#repository) - [Dependency](#dependency) - [Authentication](#authentication) - [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) @@ -21,10 +19,11 @@ This SDK supports Featurevisor v3 behaviour and v2 datafiles. Generated datafile - [Check if enabled](#check-if-enabled) - [Getting variation](#getting-variation) - [Getting variables](#getting-variables) + - [Getting feature variables](#getting-feature-variables) - [Type specific methods](#type-specific-methods) -- [Getting global variables](#getting-global-variables) -- [Getting aggregate evaluations](#getting-aggregate-evaluations) -- [Sticky features and variables](#sticky-features-and-variables) + - [Getting global variables](#getting-global-variables) +- [Getting evaluations](#getting-evaluations) +- [Sticky](#sticky) - [Setting datafile](#setting-datafile) - [Merging by default](#merging-by-default) - [Replacing](#replacing) @@ -51,6 +50,7 @@ This SDK supports Featurevisor v3 behaviour and v2 datafiles. Generated datafile - [Test](#test) - [Benchmark](#benchmark) - [Assess distribution](#assess-distribution) +- [GitHub repositories](#github-repositories) - [Development of this package](#development-of-this-package) - [Setting up](#setting-up) - [Running tests](#running-tests) @@ -61,6 +61,8 @@ This SDK supports Featurevisor v3 behaviour and v2 datafiles. Generated datafile ## Installation +Upgrading an existing application? Follow the [Java migration guide](https://featurevisor.com/docs/migrations/v4/java/). + In your Java application, update `pom.xml` to add the following: ### Repository @@ -86,12 +88,12 @@ Add the Featurevisor Java SDK as a dependency with your desired version: com.featurevisor featurevisor-java - 4.1.0 + 5.0.0 ``` -Find latest version here: [https://github.com/featurevisor/featurevisor-java/packages](https://github.com/featurevisor/featurevisor-java/packages) +Browse [Java SDK packages](https://github.com/featurevisor/featurevisor-java/packages) ### Authentication @@ -115,72 +117,76 @@ To authenticate with GitHub Packages, in your `~/.m2/settings.xml` file, add the ``` -You can generate a new GitHub token with `read:packages` scope here: [https://github.com/settings/tokens](https://github.com/settings/tokens) +Generate a token with `read:packages` scope in [GitHub token settings](https://github.com/settings/tokens) Example applications: - [Java application](https://github.com/featurevisor/featurevisor-example-java) - [Kotlin Android application](https://github.com/featurevisor/featurevisor-example-android) -## Public API +## Initialization -The main runtime API is `Featurevisor.createFeaturevisor()`: +Read a built [datafile](https://featurevisor.com/docs/building-datafiles/) from a local file and pass its content to the SDK. Save this Java 11 example as `Main.java` and place `featurevisor-all.json` in your working directory: ```java -import com.featurevisor.sdk.FeaturevisorLogLevel; - -Featurevisor f = Featurevisor.createFeaturevisor( - new Featurevisor.FeaturevisorOptions().datafile(datafileContent) -); +import com.featurevisor.sdk.Featurevisor; +import com.featurevisor.sdk.DatafileContent; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +public class Main { + public static void main(String[] args) throws Exception { + String json = Files.readString(Path.of("featurevisor-all.json")); + DatafileContent datafileContent = DatafileContent.fromJson(json); + Featurevisor f = Featurevisor.createFeaturevisor( + new Featurevisor.FeaturevisorOptions().datafile(datafileContent) + ); + try { + System.out.println(f.isEnabled("checkout", Map.of("userId", "123"))); + } finally { + f.close(); + } + } +} ``` -Most applications only need `Featurevisor.createFeaturevisor`, the `Featurevisor` instance type, and `Featurevisor.FeaturevisorOptions`. Public extension and observability types include `FeaturevisorModule`, `FeaturevisorDiagnostic`, and the datafile model types. +File read and JSON parsing failures propagate to the caller. You can also load the content with Java's HTTP client. Check for a successful HTTP status before passing the response body to the SDK. The following sections cover the available options. -Concurrent evaluations are safe after an instance is configured. Do not call state changing methods such as `setDatafile`, `setContext`, `setStickyFeatures`, `setStickyVariables`, `addModule`, `removeModule`, or `close` concurrently with evaluations or with each other. Apply those changes from a serialized update path. Module, event, and diagnostic callbacks must synchronize mutable state that they capture. - -## Initialization +### Public API -The SDK can be initialized by passing [datafile](https://featurevisor.com/docs/building-datafiles/) content directly: +The main runtime API is `Featurevisor.createFeaturevisor()`: ```java -import com.featurevisor.sdk.Featurevisor; - -// Load datafile content -String datafileUrl = "https://cdn.yoursite.com/datafile.json"; -String datafileContent = "..." // load your datafile content +import com.featurevisor.sdk.FeaturevisorLogLevel; -// Create SDK instance Featurevisor f = Featurevisor.createFeaturevisor( new Featurevisor.FeaturevisorOptions().datafile(datafileContent) ); ``` -or by constructing a `Featurevisor.FeaturevisorOptions` object: - -```java -Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() - .datafile(datafileContent) -); -``` +Most applications only need `Featurevisor.createFeaturevisor`, the `Featurevisor` instance type, and `Featurevisor.FeaturevisorOptions`. Public extension and observability types include `FeaturevisorModule`, `FeaturevisorDiagnostic`, and the datafile model types. -We will learn about several different options in the next sections. +Concurrent evaluations are safe after an instance is configured. Do not call state changing methods such as `setDatafile`, `setContext`, `setStickyFeatures`, `setStickyVariables`, `addModule`, `removeModule`, or `close` concurrently with evaluations or with each other. Apply those changes from a serialized update path. Module, event, and diagnostic callbacks must synchronize mutable state that they capture. -## Evaluation types +### Evaluation types -We can evaluate flags, variations, variables inside features, and [global variables](https://featurevisor.com/docs/global-variables/): +Featurevisor evaluates four kinds of values: - [**Flag**](#check-if-enabled) (`boolean`): whether the feature is enabled or not -- [**Variation**](#getting-variation) (`Object`): the variation of the feature (if any) -- [**Variables**](#getting-variables): variable values of the feature (if any) -- [**Global variables**](#getting-global-variables): reusable values that are not owned by a feature +- [**Variation**](#getting-variation) (`String`): the variation of the feature (if any) +- [**Feature variable**](#getting-feature-variables): a configuration value owned by a feature +- [**Global variable**](#getting-global-variables): a configuration value evaluated independently of a feature These evaluations are run against the provided context. ## Context -Contexts are [attribute](https://featurevisor.com/docs/attributes) values that we pass to SDK for evaluating [features](https://featurevisor.com/docs/features) against. +Contexts are [attribute](https://featurevisor.com/docs/attributes) values you pass to the SDK for evaluating [features](https://featurevisor.com/docs/features) against. + +Conditions compare these attributes using the [operators](https://featurevisor.com/docs/operators/), including `empty` and `notEmpty`. -Think of the conditions that you define in your [segments](https://featurevisor.com/docs/segments/), which are used in your feature's [rules](https://featurevisor.com/docs/features/#rules). +Date comparisons with `before` and `after` accept portable timestamp strings, `Instant`, `java.util.Date` (including `java.sql.Timestamp`), `OffsetDateTime` and `ZonedDateTime`. Native values are compared by their instant and truncated to milliseconds without rounding. Both the native written year, where present, and the UTC year must be within 1 to 9999. Explicit native offsets must have a magnitude of at most 14 hours, including any seconds. Offsets within that limit retain their full precision. `Instant` and `java.util.Date` have only an instant, so no offset check applies. `LocalDate` and `LocalDateTime` are not accepted because they do not identify an instant. Invalid values make both operators return `false`. They are plain maps: @@ -191,11 +197,9 @@ context.put("country", "nl"); // ...other attributes ``` -Context can be passed to SDK instance in various different ways, depending on your needs: - ### Setting initial context -You can set context at the time of initialization: +Set context when you create the instance: ```java Map initialContext = new HashMap<>(); @@ -211,7 +215,7 @@ This is useful for values that don't change too frequently and available at the ### Setting after initialization -You can also set more context after the SDK has been initialized: +Add context later: ```java Map additionalContext = new HashMap<>(); @@ -225,7 +229,7 @@ This will merge the new context with the existing one (if already set). ### Replacing existing context -If you wish to fully replace the existing context, you can pass `true` in second argument: +Replace the context by passing `true` as the second argument: ```java Map newContext = new HashMap<>(); @@ -239,28 +243,26 @@ f.setContext(newContext, true); // replace existing context ### Manually passing context -You can optionally pass additional context manually for each and every evaluation separately, without needing to set it to the SDK instance affecting all evaluations: +Pass context to one evaluation without changing the instance context: ```java Map context = new HashMap<>(); context.put("userId", "123"); context.put("country", "nl"); -boolean isEnabled = f.isEnabled("my_feature", context); -String variation = f.getVariation("my_feature", context); -String variableValue = f.getVariableString("my_feature", "my_variable", context); +boolean isEnabled = f.isEnabled("checkout", context); +String variation = f.getVariation("checkout", context); +String variableValue = f.getVariableString("checkout", "bgColor", context); ``` When manually passing context, it will merge with existing context set to the SDK instance before evaluating the specific value. -Further details for each evaluation types are described below. - ## Check if enabled -Once the SDK is initialized, you can check if a feature is enabled or not: +Check whether a feature is enabled: ```java -String featureKey = "my_feature"; +String featureKey = "checkout"; boolean isEnabled = f.isEnabled(featureKey); @@ -269,7 +271,7 @@ if (isEnabled) { } ``` -You can also pass additional context per evaluation: +Pass additional context to one evaluation: ```java Map additionalContext = new HashMap<>(); @@ -280,10 +282,10 @@ boolean isEnabled = f.isEnabled(featureKey, additionalContext); ## Getting variation -If your feature has any [variations](https://featurevisor.com/docs/features/#variations) defined, you can evaluate them as follows: +Evaluate a feature's [variations](https://featurevisor.com/docs/features/#variations): ```java -String featureKey = "my_feature"; +String featureKey = "checkout"; String variation = f.getVariation(featureKey); @@ -294,7 +296,7 @@ if ("treatment".equals(variation)) { } ``` -Additional context per evaluation can also be passed: +Pass additional context to one evaluation: ```java String variation = f.getVariation(featureKey, additionalContext); @@ -302,7 +304,9 @@ String variation = f.getVariation(featureKey, additionalContext); ## Getting variables -Your features may also include [variables](https://featurevisor.com/docs/features/#variables), which can be evaluated as follows: +### Getting feature variables + +Evaluate a feature's [variables](https://featurevisor.com/docs/features/#variables): ```java String variableKey = "bgColor"; @@ -310,7 +314,7 @@ String variableKey = "bgColor"; String bgColorValue = f.getVariableString(featureKey, variableKey); ``` -Additional context per evaluation can also be passed: +Pass additional context to one evaluation: ```java String bgColorValue = f.getVariableString(featureKey, variableKey, additionalContext); @@ -365,7 +369,7 @@ Map> nested = f.getVariableObject( ); ``` -Typed overloads are additive and non-breaking. If decoding fails for the requested target type, these methods return `null`. +If decoding fails for the requested target type, these methods return `null`. For dynamic JSON values with unknown shape, use `getVariableJSONNode`: @@ -385,7 +389,7 @@ If a variable schema type is `json` and the resolved value is a malformed string - `getVariableJSONNode(...)` - `getVariableJSON(...)` -## Getting global variables +### Getting global variables Global variables use the same overloaded methods as variables inside features. A call with one key evaluates a global variable, while a call with a feature key and variable key evaluates a variable owned by that feature: @@ -397,11 +401,35 @@ Evaluation evaluation = f.evaluateVariable("checkoutSettings", context); The type specific methods are `getVariableBoolean`, `getVariableString`, `getVariableInteger`, `getVariableDouble`, `getVariableArray`, `getVariableObject`, and `getVariableJSON`. -Global variables resolve sticky values first, then required features, then the first matching override, and finally their default value. If required features are unmet, `disabledValue` is used unless `useDefaultWhenDisabled` is enabled. Caller defaults are only used when the evaluation itself has no value. +Root conditions and overrides are applied for you. See [Variables](https://featurevisor.com/docs/variables/#global-variables) and [Conditions](https://featurevisor.com/docs/conditions/) for authoring. + +### Conditions and composed variables + +Conditions combine attribute comparisons, literal segment keys, feature getters and variable comparisons. Arrays imply AND. Logical groups use `and`, `or` and `not`, with NOT negating the AND of its children. Empty expressions are false. Variable paths in generated datafiles are compiled arrays, not authored dotted paths. + +Feature evaluation checks sticky values, force rules, root conditions and traffic rules in that order. Global variables check sticky values and root conditions before sources and overrides. An unmet root returns `conditions_unmet` and exposes `Evaluation.getConditions()`. Disabled variations and feature variables retain their existing disabled reasons. + +Composed global variables use the ordinary getters: + +```java +Object screen = f.getVariable("checkoutScreen", context); +Object policy = f.getVariable("policyAlias", context); +Evaluation evaluation = f.evaluateVariable("checkoutScreen", context); +``` + +A map composition assembles a fresh outer object with literal properties from feature flags, variations, feature variables and global variables. A string composition aliases one literal global variable key and returns its value directly. Sources use effective context, child sticky state and modules. False, zero, empty strings, arrays and explicit null remain values. JSON sources decode exactly once, even through alias chains. An outer caller default is never forwarded to sources. + +Compositions have no defaultValue or useDefaultWhenDisabled. Root conditions can return disabledValue or null without reading sources or overrides. Whole variable sticky state bypasses composition. Sources are read before runtime overrides, whose mutation operations copy changed paths without modifying sources, siblings or sticky values. Successful composition reports `variable_composed`, or `variable_override_rule` when an override matches. + +Treat supplied datafiles and returned objects as read only. Copy values before changing them. Composed members and string aliases retain ordinary getter ownership. Only the map's outer object is freshly assembled. + +Conditions, segments and composition share cycle detection and a combined dependency depth bound of 256. A cycle or exhausted depth fails the complete evaluation through its ordinary error result. A thread stack too small for the chain fails the same way, and getters never throw `StackOverflowError`. A subsequent public call starts a fresh session. -## Getting aggregate evaluations +To see which overrides changed a value, read `variableOverridePaths` from `evaluateVariable`. The singular override fields describe the final match. -You can get evaluations of all features available in the SDK instance: +## Getting evaluations + +Evaluate all features available in the instance: ```java import com.featurevisor.sdk.EvaluatedFeatures; @@ -414,7 +442,7 @@ Map evaluations = allEvaluations.getValue(); System.out.println(evaluations); // { -// "myFeature": { +// "checkout": { // "enabled": true, // "variation": "control", // "variables": { @@ -437,24 +465,24 @@ Global variables can be evaluated together as well: Map variables = f.getVariableEvaluations(context, null, null); ``` -## Sticky features and variables +## Sticky -For the lifecycle of the SDK instance in your application, you can set some features with sticky values, meaning that they will not be evaluated against the fetched [datafile](https://featurevisor.com/docs/building-datafiles/): +Set sticky values to take precedence over feature evaluation against the [datafile](https://featurevisor.com/docs/building-datafiles/): Sticky values belong to an SDK or child instance. Feature sticky values and global variable sticky values are independent. ```java Map stickyFeatures = new HashMap<>(); -Map myFeatureSticky = new HashMap<>(); -myFeatureSticky.put("enabled", true); -myFeatureSticky.put("variation", "treatment"); +Map checkoutSticky = new HashMap<>(); +checkoutSticky.put("enabled", true); +checkoutSticky.put("variation", "treatment"); -Map myVariables = new HashMap<>(); -myVariables.put("myVariableKey", "myVariableValue"); -myFeatureSticky.put("variables", myVariables); +Map checkoutVariables = new HashMap<>(); +checkoutVariables.put("myVariableKey", "myVariableValue"); +checkoutSticky.put("variables", checkoutVariables); -stickyFeatures.put("myFeatureKey", myFeatureSticky); +stickyFeatures.put("checkout", checkoutSticky); Map anotherFeatureSticky = new HashMap<>(); anotherFeatureSticky.put("enabled", false); @@ -468,14 +496,14 @@ Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOp Once initialized with sticky features, the SDK will look for values there first before evaluating the targeting conditions and going through the bucketing process. -You can also set sticky features after the SDK is initialized: +Set sticky features after initialization: ```java Map stickyFeatures = new HashMap<>(); // ... build sticky features map f.setStickyFeatures(stickyFeatures, true); // replace existing sticky features -f.setStickyVariables(Map.of("welcomeMessage", "Welcome back"), true); +f.setStickyVariables(Map.of("welcomeMessage", "Welcome back"), true); // replace ``` ## Setting datafile @@ -486,47 +514,40 @@ You may also initialize the SDK without passing `datafile`, and set it later on: f.setDatafile(datafileContent); ``` +`setDatafile` accepts either a parsed `DatafileContent` or a raw JSON string. All overloads return `true` when the datafile is accepted, including unchanged content, and `false` when the input is rejected or the instance is closed. Rejected input leaves the last accepted datafile in place and reports the existing diagnostic and error event, without a `datafile_set` event. Calls after closing do nothing. Accepted calls still emit `datafile_set`, even when nothing changed. The return value reports acceptance, not whether any features changed. + +```java +boolean accepted = f.setDatafile(datafileContent); +``` + ### Merging by default By default, `setDatafile(datafile)` merges the incoming datafile with the SDK's stored datafile. Incoming top level metadata is used, and incoming segments, features, and global variables override existing entities with the same keys. -This means you can call `setDatafile` 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 `setDatafile` with different datafiles to merge their features, segments, and global variables. Use this to [load datafiles on demand](#loading-datafiles-on-demand). ### Replacing To replace the stored datafile entirely, pass `true`: ```java -f.setDatafile(datafileContent, true); +f.setDatafile(datafileContent, true); // replace ``` ### Loading datafiles on demand -Because merging is the default, a single SDK instance can start with a small datafile and load more datafiles later as your application needs them, instead of downloading every feature upfront. - -This pairs well with [targets](https://featurevisor.com/docs/targets/), where each target produces a smaller datafile for a specific part of your application: +Merge datafiles for different [targets](https://featurevisor.com/docs/targets/) into the same instance. Here, the two datafile values have already been loaded and validated by your application from the same published release: ```java -Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions()); - -void loadDatafile(String target) throws Exception { - String url = "https://cdn.yoursite.com/production/featurevisor-" + target + ".json"; - String json = fetchJson(url); // use your HTTP client of choice - DatafileContent datafile = DatafileContent.fromJson(json); - - // merges into whatever was loaded before - f.setDatafile(datafile); -} - -loadDatafile("products"); - -// later, when the user reaches checkout -loadDatafile("checkout"); +f.setDatafile(productsDatafile); +f.setDatafile(checkoutDatafile); ``` +Use immutable release URLs, check HTTP responses and keep shared dependencies consistent. See [Loading datafiles on demand](https://featurevisor.com/docs/use-cases/on-demand-datafiles/) for the complete loading and refresh flow. + ### Updating datafile -You can set the datafile as many times as you want in your application, which will result in emitting a [`datafile_set`](#datafile-set) event that you can listen and react to accordingly. +Each accepted datafile update emits a [`datafile_set`](#datafile-set) event. Subscribe to react to updates. The triggers for setting the datafile again can be: @@ -537,21 +558,42 @@ The triggers for setting the datafile again can be: ### Interval-based update -Here's an example of using interval-based update: +Replace the complete datafile on each refresh so definitions removed upstream are removed from the SDK too. Use merging only when loading partial datafiles intentionally. ```java -// Using ScheduledExecutorService for periodic updates +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import com.featurevisor.sdk.DatafileContent; + +// f is the existing Featurevisor instance +HttpClient client = HttpClient.newHttpClient(); +HttpRequest request = HttpRequest.newBuilder(URI.create(datafileUrl)) + .timeout(Duration.ofSeconds(30)) + .build(); ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); scheduler.scheduleAtFixedRate(() -> { - // Fetch new datafile content - String newDatafileContent = // ... fetch from your CDN - DatafileContent newDatafile = DatafileContent.fromJson(newDatafileContent); - - // Merge into the SDK's existing datafile - f.setDatafile(newDatafile); + try { + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() == 200) { + DatafileContent newDatafile = DatafileContent.fromJson(response.body()); + f.setDatafile(newDatafile, true); // replace + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + System.err.println("Failed to update datafile: " + e.getMessage()); + } }, 0, 5, TimeUnit.MINUTES); ``` +Call `scheduler.shutdownNow()` when your application shuts down. + ## Diagnostics By default, Featurevisor reports diagnostics to the console for `info` level and above with a `[Featurevisor]` prefix. @@ -582,16 +624,19 @@ Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOp })); ``` -Every diagnostic has `level`, `code`, `message`, and an object-shaped `details` map. Optional `module`, `moduleName`, and `originalError` fields describe provenance. Evaluation metadata belongs in `details`. +Generated datafiles use schemaVersion `"4"`. Schema `"2"` selectors normalize eagerly when accepted, while schema `"4"` entities normalize on first read. Partial updates invalidate only incoming entities. Replacement clears all entity caches. Legacy compatibility conversion does not change caller objects or the informational version metadata. -Diagnostic handlers are isolated from SDK behaviour. An exception in a handler does not stop other handlers or evaluations. +Malformed stringified conditions fail only their complete selector, including through NOT or segment aliases, without rejecting the datafile. `conditions_parse_error` is reported at error level once per selector per incoming entity load. Its details are exactly `conditions`, `entityType` and `entityKey`. Eager diagnostics run after storage, and lazy diagnostics run after caching, so callbacks may safely reenter getters. + +Every diagnostic has `level`, `code`, `message`, and an object shaped `details` map. Optional `module`, `moduleName`, and `originalError` fields describe provenance. Evaluation metadata belongs in `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. -You can listen to these events that can occur at various stages in your application: +Subscribe to instance lifecycle events: ### `datafile_set` @@ -616,7 +661,7 @@ FeaturevisorUnsubscribe unsubscribe = f.on(FeaturevisorEventName.DATAFILE_SET, ( unsubscribe.unsubscribe(); ``` -The `features` and `variables` arrays contain directly changed entities and entities affected through segment or required feature dependencies. +The `features` and `variables` arrays contain directly changed entities and their transitive dependants through conditions, segments and composition, including removed dependencies. - added, or - updated, or @@ -670,7 +715,7 @@ The `error` event is emitted for diagnostics whose level is `ERROR`. ## Evaluation details -Besides logging with debug level enabled, you can also get more details about how the feature variations and variables are evaluated in the runtime against given context: +Inspect how variations and variables are evaluated against a context: ```java // flag @@ -701,15 +746,24 @@ And optionally these properties depending on whether you are evaluating a featur - `variationValue`: the variation value - `variableKey`: the variable key - `variableValue`: the variable value -- `variableSchema`: the variable schema +- `variableSchema`: the feature variable schema +- `variable`: the global variable definition +- `conditions`: the unmet root expression +- `variableOverrideIndex`, `variableOverrideKey`, `variableOverridePath`, `variableOverridePaths`: matched override provenance ## 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 allow you to intercept the evaluation process and customize it further as per your needs. -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 in registration order for feature and global variable evaluations. Condition and composition reads use the same complete module pipeline, and transformed defaults are preserved. One pair surrounds a complete accumulating evaluation, not each mutation operation. + +Module options expose `getDependencyOf()` for an internal getter read. It identifies the immediate requester by type and optional feature and variable keys. `getDependencyPurpose()` is `condition` for eligibility checks or `composition` for consumed values. Application calls, including an independent public getter invoked from a module, have neither marker. Repeated equivalent internal reads can be memoized within one public call, separately for each purpose and requester. -`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. +Treat sticky maps and their nested values as read only after passing them to an instance or module. Use sticky setters or return replacement maps from a module to change them. Mutating an existing map in place is not supported. + +Modules should return the same result for the same input during one evaluation. Pass values that change through context. ### Defining a module @@ -724,15 +778,12 @@ FeaturevisorModule myCustomModule = new FeaturevisorModule("my-custom-module") }) // before evaluation - .before(options -> { + .beforeEvaluation(options -> { Map context = new HashMap<>(options.getContext()); context.put("someAdditionalAttribute", "value"); return options.copy().context(context); }) - // before any feature or global variable evaluation - .beforeEvaluation(options -> options) - // configure bucket key .bucketKey(options -> { String bucketKey = options.getBucketKey(); @@ -746,9 +797,6 @@ FeaturevisorModule myCustomModule = new FeaturevisorModule("my-custom-module") }) // after evaluation - .after((evaluation, options) -> evaluation) - - // after any feature or global variable evaluation .afterEvaluation((evaluation, options) -> evaluation) // called by f.close() @@ -759,7 +807,7 @@ FeaturevisorModule myCustomModule = new FeaturevisorModule("my-custom-module") ### Registering modules -You can register modules at the time of SDK initialization: +Register modules when you create the instance: ```java List modules = new ArrayList<>(); @@ -813,12 +861,12 @@ childContext.put("userId", "123"); ChildInstance childF = f.spawn(childContext); ``` -Now you can pass the child instance where your individual request is being handled, and you can continue to evaluate features targeting that specific user alone: +Pass the child instance to the request handler to evaluate features for that user: ```java -boolean isEnabled = childF.isEnabled("my_feature"); -String variation = childF.getVariation("my_feature"); -String variableValue = childF.getVariableString("my_feature", "my_variable"); +boolean isEnabled = childF.isEnabled("checkout"); +String variation = childF.getVariation("checkout"); +String variableValue = childF.getVariableString("checkout", "bgColor"); ``` Similar to parent SDK, child instances also support several additional methods: @@ -853,44 +901,6 @@ Both primary and child instances support a `.close()` method, that removes forgo f.close(); ``` -## CLI usage - -This package also provides a CLI tool for running your Featurevisor project's test specs and benchmarking against this Java SDK: - -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 - -Learn more about testing [here](https://featurevisor.com/docs/testing/). - -```bash -$ mvn exec:java -Dexec.mainClass="com.featurevisor.cli.CLI" -Dexec.args="test --projectDirectoryPath=/absolute/path/to/your/featurevisor/project" -``` - -Additional options that are available: - -```bash -$ mvn exec:java -Dexec.mainClass="com.featurevisor.cli.CLI" -Dexec.args="test --projectDirectoryPath=/absolute/path/to/your/featurevisor/project --quiet --onlyFailures --keyPattern=myFeatureKey --assertionPattern=#1 --showDatafile --inflate=1" -``` - -The test runner builds base datafiles and Target datafiles, then uses a Target datafile when an assertion contains `target`. - -### Benchmark - -Learn more about benchmarking [here](https://featurevisor.com/docs/cli/#benchmarking). - -```bash -$ mvn exec:java -Dexec.mainClass="com.featurevisor.cli.CLI" -Dexec.args="benchmark --projectDirectoryPath=/absolute/path/to/your/featurevisor/project --environment=production --feature=myFeatureKey --context='{\"country\": \"nl\"}' --n=1000" -``` - -### Assess distribution - -Learn more about assessing distribution [here](https://featurevisor.com/docs/cli/#assess-distribution). - -```bash -$ mvn exec:java -Dexec.mainClass="com.featurevisor.cli.CLI" -Dexec.args="assess-distribution --projectDirectoryPath=/absolute/path/to/your/featurevisor/project --environment=production --feature=foo --variation --context='{\"country\": \"nl\"}' --populateUuid=userId --populateUuid=deviceId --n=1000" -``` - ## OpenFeature The OpenFeature provider is published as a separate artifact. Applications that use only `com.featurevisor:featurevisor-java` receive no provider classes or OpenFeature dependency. @@ -901,13 +911,11 @@ Add the provider with the same version as the Featurevisor Java SDK: com.featurevisor featurevisor-openfeature - 4.1.0 + 5.0.0 ``` -The provider artifact depends on the matching Featurevisor Java SDK and the compatible official OpenFeature SDK, so no additional dependency is required. - -When upgrading from an earlier release, replace the direct `dev.openfeature:sdk` dependency with `com.featurevisor:featurevisor-openfeature`. The provider's Java package and public API are unchanged. +The provider artifact depends on the matching Featurevisor Java SDK and the official OpenFeature Java SDK 1.23.0, so no additional dependency is required. Both retain Java 11 as the minimum runtime. ```java import com.featurevisor.openfeature.FeaturevisorOpenFeatureProvider; @@ -930,7 +938,7 @@ Use `checkout` for a flag, `checkout:variation` for its variation, `checkout:tit OpenFeature's targeting key maps to `userId` by default. `targetingKeyField`, `keySeparator`, `variationKey`, and `globalVariablePrefix` on `FeaturevisorOpenFeatureProvider.Options` can customize the mapping. The global variable prefix defaults to `variable` and cannot contain the configured separator. -You can also reuse an existing Featurevisor instance: +Reuse an existing Featurevisor instance: ```java Featurevisor featurevisor = Featurevisor.createFeaturevisor(featurevisorOptions); @@ -939,10 +947,92 @@ var provider = new FeaturevisorOpenFeatureProvider(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 configured, the existing instance takes precedence. +An invalid initial datafile in an owned instance fails `setProviderAndWait` with a parse error, independently of the diagnostic log level. Valid updates clear the error and emit readiness. Supplied diagnostic callbacks and module setup remain unchanged, and shutdown closes only an owned instance. + +Composed values map to `TARGETING_MATCH` without additional JSON decoding. Unmet root conditions map to `DISABLED`. Ordinary null values retain the provider default policy. + 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 + +Compile the CLI module from this SDK repository: + +```bash +mvn -pl featurevisor-sdk compile +``` + +The native test runner requires `@featurevisor/cli` 4.0.0 or later and Node.js 24 or later. Install the Node.js CLI in your Featurevisor project: + +```bash +npm install --save-dev @featurevisor/cli@^4.0.0 +``` + +Project parsing and datafile generation use `npx featurevisor`. Evaluations run through this Java SDK. The SDK runtime has no Node.js dependency. All three commands accept repeated `--target=` options. + +Use the Node.js CLI directly for authoring commands such as `init`, `lint`, `build` and `list`. Native commands delegate project parsing, test expansion and datafile generation to Node.js, then evaluate the results with this SDK. + +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. + +`--n` and `-n` accept both value forms. Repeat `--populate-uuid` for multiple context attributes. Quote JSON and paths containing spaces. + +### Test + +```bash +mvn -q -pl featurevisor-sdk exec:java -Dexec.mainClass=com.featurevisor.cli.CLI \ + -Dexec.args="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. + +Segment specs and checks evaluate ordinary SDK gates with the selected datafile, context, fixed bucket and isolated sticky state. Compiled execution definitions restore missing segments. Existing Target segment specializations remain authoritative. Attribute only segment chains can omit the environment, while segments depending on features or variables require the selected datafile. All transitive root and child check references are preflighted before getters. Synthetic segment gates do not count towards coverage. + +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. + +JSON rerun arguments use `argumentEncoding: maven-exec`. Pass the shell quoted native argument list as one `-Dexec.args` value, or copy the printed rerun command. + +### 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. Java evaluates them. Replace `checkout` with a feature defined in your project. + +```bash +mvn -q -pl featurevisor-sdk exec:java -Dexec.mainClass=com.featurevisor.cli.CLI \ + -Dexec.args="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 +mvn -q -pl featurevisor-sdk exec:java -Dexec.mainClass=com.featurevisor.cli.CLI \ + -Dexec.args="assess-distribution --project-directory-path=/path/to/featurevisor-project --feature=checkout --populate-uuid=userId --n=1000" +``` + +The Java CLI also accepts `--variation` or `--variable=` to assess a variation or feature variable instead of the enabled state. These selectors are specific to this runner and are not supported by the Node.js `assess-distribution` command. Repeat `--populate-uuid=` to generate UUID values for multiple context attributes. See [Assess distribution](https://featurevisor.com/docs/cli/#assess-distribution). + +## GitHub repositories + +- SDK: [featurevisor/featurevisor-java](https://github.com/featurevisor/featurevisor-java) +- Example application: [featurevisor/featurevisor-example-java](https://github.com/featurevisor/featurevisor-example-java) +- Kotlin Android example application: [featurevisor/featurevisor-example-android](https://github.com/featurevisor/featurevisor-example-android) + + + +The aggregate evaluation reference has moved to [Getting evaluations](#getting-evaluations). + + + +The sticky state reference has moved to [Sticky](#sticky). + ## Development of this package ### Setting up @@ -959,6 +1049,27 @@ $ mvn install $ mvn test ``` +Compile the actual initialization snippet for Java 11 and execute it with an isolated local datafile: + +```bash +mvn -Dmaven.test.skip=true package +bash scripts/test-readme-initialization.sh +``` + +The README check also covers missing files and invalid JSON. CI runs it on both configured JDKs. Pass `--offline` to reuse cached Maven dependencies locally. + +To check runtime command selection against an already built Node.js monorepo: + +```bash +mvn -pl featurevisor-sdk test-compile dependency:build-classpath -Dmdep.outputFile=target/runtime-classpath.txt +node scripts/test-runtime-commands.mjs ../featurevisor java \ + -cp "featurevisor-sdk/target/classes:$(cat featurevisor-sdk/target/runtime-classpath.txt)" \ + com.featurevisor.cli.CLI +bash scripts/test-release.sh 5.0.0 +``` + +These checks create temporary flat, environment and sets projects without changing the monorepo or downloading packages. The classpath command above uses the POSIX `:` separator. + The repository is a Maven reactor with two published libraries: - `com.featurevisor:featurevisor-java` @@ -972,8 +1083,10 @@ $ make verify-artifacts ### Releasing +The SDK, provider and parent POM share version `5.0.0`. Before tagging, run `bash scripts/verify-release.sh v5.0.0` to check the Maven revision, CLI and both README installation examples. + 1. Merge the release changes into `main`. -2. Tag the release with a `v` prefix, such as `v4.1.0`, and push the tag. +2. Tag the release with a `v` prefix, such as `vX.Y.Z`, and push the tag. 3. GitHub Actions verifies and publishes the parent POM, Java SDK, and OpenFeature provider to [GitHub Packages](https://github.com/orgs/featurevisor/packages?repo_name=featurevisor-java). 4. Create the corresponding [GitHub release](https://github.com/featurevisor/featurevisor-java/releases). 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/featurevisor-openfeature/src/main/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProvider.java b/featurevisor-openfeature/src/main/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProvider.java index 23d4480..f8e6b1d 100644 --- a/featurevisor-openfeature/src/main/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProvider.java +++ b/featurevisor-openfeature/src/main/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProvider.java @@ -1,15 +1,19 @@ package com.featurevisor.openfeature; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.featurevisor.sdk.Evaluation; import com.featurevisor.sdk.Featurevisor; -import com.featurevisor.sdk.FeaturevisorDiagnosticHandler; +import com.featurevisor.sdk.FeaturevisorDiagnostic; import com.featurevisor.sdk.FeaturevisorEventName; import com.featurevisor.sdk.FeaturevisorUnsubscribe; import com.featurevisor.sdk.VariableType; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.EventProvider; +import dev.openfeature.sdk.ProviderEventDetails; +import dev.openfeature.sdk.ProviderState; +import dev.openfeature.sdk.exceptions.ParseError; import dev.openfeature.sdk.ImmutableMetadata; import dev.openfeature.sdk.Metadata; import dev.openfeature.sdk.ProviderEvaluation; @@ -23,9 +27,10 @@ import java.util.List; import java.util.Map; -/** OpenFeature provider backed by the Featurevisor v3 SDK. */ -public final class FeaturevisorOpenFeatureProvider implements FeatureProvider { - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); +/** OpenFeature provider backed by the Featurevisor SDK. */ +public final class FeaturevisorOpenFeatureProvider extends EventProvider { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper() + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); @FunctionalInterface public interface TrackingHandler { @@ -57,8 +62,9 @@ public static final class Options { private final String globalVariablePrefix; private final TrackingHandler onTrack; private final FeaturevisorUnsubscribe datafileUnsubscribe; + private final FeaturevisorUnsubscribe errorUnsubscribe; private final boolean ownsFeaturevisor; - private String datafileError; + private volatile String datafileError; public FeaturevisorOpenFeatureProvider(Options options) { Options resolved = options != null ? options : new Options(); @@ -76,22 +82,42 @@ public FeaturevisorOpenFeatureProvider(Options options) { } else { Featurevisor.FeaturevisorOptions fvOptions = resolved.featurevisorOptions != null ? resolved.featurevisorOptions : new Featurevisor.FeaturevisorOptions(); - if (fvOptions.getDatafileString() != null) { - try { - com.featurevisor.sdk.DatafileContent.fromJson(fvOptions.getDatafileString()); - } catch (Exception ignored) { - datafileError = "Could not parse datafile"; + this.featurevisor = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .context(fvOptions.getContext()).logLevel(fvOptions.getLogLevel()) + .stickyFeatures(fvOptions.getStickyFeatures()).stickyVariables(fvOptions.getStickyVariables()) + .modules(fvOptions.getModules()).onDiagnostic(fvOptions.getOnDiagnostic() != null + ? fvOptions.getOnDiagnostic() : diagnostic -> {})); + } + this.datafileUnsubscribe = this.featurevisor.on(FeaturevisorEventName.DATAFILE_SET, details -> { + boolean recovering = datafileError != null; + datafileError = null; + if (recovering) emitProviderReady(ProviderEventDetails.builder().build()); + }); + this.errorUnsubscribe = this.featurevisor.on(FeaturevisorEventName.ERROR, details -> { + Object diagnostic = details.get("diagnostic"); + if (diagnostic instanceof FeaturevisorDiagnostic) { + FeaturevisorDiagnostic value = (FeaturevisorDiagnostic) diagnostic; + if ("invalid_datafile".equals(value.getCode())) { + datafileError = value.getMessage(); + emitProviderError(ProviderEventDetails.builder().message(datafileError).errorCode(ErrorCode.PARSE_ERROR).build()); } } - FeaturevisorDiagnosticHandler original = fvOptions.getOnDiagnostic(); - fvOptions.onDiagnostic(diagnostic -> { - if ("invalid_datafile".equals(diagnostic.getCode())) datafileError = diagnostic.getMessage(); - if ("datafile_set".equals(diagnostic.getCode())) datafileError = null; - if (original != null) original.handle(diagnostic); - }); - this.featurevisor = Featurevisor.createFeaturevisor(fvOptions); + }); + if (ownsFeaturevisor && resolved.featurevisorOptions != null) { + if (resolved.featurevisorOptions.getDatafile() != null) { + this.featurevisor.setDatafile(resolved.featurevisorOptions.getDatafile(), true); + } else if (resolved.featurevisorOptions.getDatafileString() != null) { + this.featurevisor.setDatafile(resolved.featurevisorOptions.getDatafileString(), true); + } } - this.datafileUnsubscribe = this.featurevisor.on(FeaturevisorEventName.DATAFILE_SET, details -> datafileError = null); + } + + @Override public void initialize(EvaluationContext context) { + if (datafileError != null) throw new ParseError(datafileError); + } + + @Override public ProviderState getState() { + return datafileError != null ? ProviderState.ERROR : ProviderState.READY; } public FeaturevisorOpenFeatureProvider(Featurevisor.FeaturevisorOptions options) { @@ -106,6 +132,7 @@ public FeaturevisorOpenFeatureProvider(Featurevisor featurevisor) { @Override public Metadata getMetadata() { return () -> "Featurevisor"; } @Override public void shutdown() { datafileUnsubscribe.unsubscribe(); + errorUnsubscribe.unsubscribe(); if (ownsFeaturevisor) featurevisor.close(); } @Override public void track(String name, EvaluationContext context, TrackingEventDetails details) { @@ -143,7 +170,7 @@ private ProviderEvaluation resolve(String flagKey, Object defaultValue, String featureKey = separatorIndex < 0 ? flagKey : flagKey.substring(0, separatorIndex); String selector = separatorIndex < 0 ? null : flagKey.substring(separatorIndex + keySeparator.length()); Map fvContext = context != null ? normalizeMap(context.asObjectMap()) : new HashMap<>(); - if (context != null && context.getTargetingKey() != null && !context.getTargetingKey().isEmpty()) { + if (context != null && context.getTargetingKey() != null) { fvContext.put(targetingKeyField, context.getTargetingKey()); } @@ -154,6 +181,7 @@ private ProviderEvaluation resolve(String flagKey, Object defaultValue, value = evaluation.getVariableValue(); if (evaluation.getVariable() != null && evaluation.getVariable().getType() == VariableType.JSON + && evaluation.getVariable().getCompose() == null && value instanceof String) { try { value = OBJECT_MAPPER.readValue((String) value, Object.class); } catch (Exception ignored) { } } @@ -205,9 +233,10 @@ private ImmutableMetadata metadata(Evaluation evaluation) { private static Reason reason(String reason) { if (List.of(Evaluation.REASON_FEATURE_NOT_FOUND, Evaluation.REASON_VARIABLE_NOT_FOUND, Evaluation.REASON_NO_VARIATIONS, Evaluation.REASON_ERROR).contains(reason)) return Reason.ERROR; - if (List.of(Evaluation.REASON_REQUIRED, Evaluation.REASON_FORCED, Evaluation.REASON_STICKY, Evaluation.REASON_RULE, Evaluation.REASON_VARIABLE_OVERRIDE_RULE, Evaluation.REASON_VARIABLE_OVERRIDE_VARIATION).contains(reason)) return Reason.TARGETING_MATCH; + if (List.of(Evaluation.REASON_FORCED, Evaluation.REASON_STICKY, Evaluation.REASON_RULE, Evaluation.REASON_VARIABLE_OVERRIDE_RULE, Evaluation.REASON_VARIABLE_OVERRIDE_VARIATION, Evaluation.REASON_VARIABLE_COMPOSED).contains(reason)) return Reason.TARGETING_MATCH; if (Evaluation.REASON_ALLOCATED.equals(reason)) return Reason.SPLIT; - if (List.of(Evaluation.REASON_DISABLED, Evaluation.REASON_VARIATION_DISABLED, Evaluation.REASON_VARIABLE_DISABLED, Evaluation.REASON_REQUIRED_FEATURES_UNMET).contains(reason)) return Reason.DISABLED; + if (List.of(Evaluation.REASON_DISABLED, Evaluation.REASON_VARIATION_DISABLED, Evaluation.REASON_VARIABLE_DISABLED, + Evaluation.REASON_CONDITIONS_UNMET).contains(reason)) return Reason.DISABLED; return Reason.DEFAULT; } @@ -233,7 +262,14 @@ private static String variant(Evaluation evaluation) { private static boolean matches(Object value, String expected) { if ("boolean".equals(expected)) return value instanceof Boolean; if ("string".equals(expected)) return value instanceof String; - if ("integer".equals(expected)) return value instanceof Number && Math.rint(((Number) value).doubleValue()) == ((Number) value).doubleValue(); + if ("integer".equals(expected)) { + if (!(value instanceof Number)) return false; + double number = ((Number) value).doubleValue(); + return Double.isFinite(number) + && number == Math.rint(number) + && number >= Integer.MIN_VALUE + && number <= Integer.MAX_VALUE; + } if ("number".equals(expected)) return value instanceof Number && Double.isFinite(((Number) value).doubleValue()); return value instanceof Map || value instanceof List; } diff --git a/featurevisor-openfeature/src/test/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProviderTest.java b/featurevisor-openfeature/src/test/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProviderTest.java index 58b63dc..8b0e1ef 100644 --- a/featurevisor-openfeature/src/test/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProviderTest.java +++ b/featurevisor-openfeature/src/test/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProviderTest.java @@ -1,6 +1,7 @@ package com.featurevisor.openfeature; import com.featurevisor.sdk.DatafileContent; +import com.featurevisor.sdk.Evaluation; import com.featurevisor.sdk.Featurevisor; import com.featurevisor.sdk.FeaturevisorLogLevel; import com.featurevisor.sdk.FeaturevisorModule; @@ -8,15 +9,184 @@ import dev.openfeature.sdk.ImmutableContext; import dev.openfeature.sdk.OpenFeatureAPI; import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Reason; import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.Structure; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class FeaturevisorOpenFeatureProviderTest { + @Test void clientDefaultsTrackingAndUpdatesWithBorrowedLifecycle() throws Exception { + AtomicBoolean closed = new AtomicBoolean(); + Featurevisor featurevisor = Featurevisor.createFeaturevisor(options().modules(List.of( + new FeaturevisorModule("owner").close(() -> closed.set(true))))); + List tracked = new ArrayList<>(); + FeaturevisorOpenFeatureProvider provider = new FeaturevisorOpenFeatureProvider( + new FeaturevisorOpenFeatureProvider.Options().featurevisor(featurevisor) + .onTrack((name, context, details) -> tracked.add(name + ":" + context.getTargetingKey()))); + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + try { + api.setProviderAndWait(provider); + var client = api.getClient(); + var error = new java.util.concurrent.CountDownLatch(1); + var errorCode = new java.util.concurrent.atomic.AtomicReference(); + client.onProviderError(event -> { + errorCode.set(event.getErrorCode()); + error.countDown(); + }); + var context = new ImmutableContext("forced-user"); + var result = client.getBooleanDetails("checkout", false, context); + assertTrue(result.getValue()); + assertEquals(Reason.TARGETING_MATCH.name(), result.getReason()); + var missing = client.getStringDetails("variable:missing", "fallback", context); + assertEquals("fallback", missing.getValue()); + assertEquals(ErrorCode.FLAG_NOT_FOUND, missing.getErrorCode()); + client.track("purchase", context); + assertEquals(List.of("purchase:forced-user"), tracked); + assertTrue(featurevisor.setDatafile(DATAFILE.replace("Hello", "Updated"), true)); + assertEquals("Updated", client.getStringValue("checkout:title", "fallback", context)); + assertFalse(featurevisor.setDatafile("{", true)); + assertTrue(error.await(2, java.util.concurrent.TimeUnit.SECONDS)); + assertEquals(ErrorCode.PARSE_ERROR, errorCode.get()); + var ready = new java.util.concurrent.CountDownLatch(1); + client.onProviderReady(event -> ready.countDown()); + assertTrue(featurevisor.setDatafile(DATAFILE, true)); + assertTrue(ready.await(2, java.util.concurrent.TimeUnit.SECONDS)); + assertTrue(client.getBooleanValue("checkout", false, context)); + api.shutdown(); + assertFalse(closed.get()); + } finally { + featurevisor.close(); + } + assertTrue(closed.get()); + } + + @Test void invalidRegistrationAndRecoveryThroughClient() throws Exception { + FeaturevisorOpenFeatureProvider provider = new FeaturevisorOpenFeatureProvider(new Featurevisor.FeaturevisorOptions() + .datafileString("{}").logLevel(FeaturevisorLogLevel.FATAL)); + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + assertThrows(Exception.class, () -> api.setProviderAndWait(provider)); + assertEquals(dev.openfeature.sdk.ProviderState.ERROR, provider.getState()); + provider.getFeaturevisor().setDatafile(DATAFILE, true); + assertEquals(dev.openfeature.sdk.ProviderState.READY, provider.getState()); + assertTrue(api.getClient().getBooleanValue("checkout", false)); + } + + @Test void initialHealthIgnoresLogFilteringAndRecovers() throws Exception { + for (FeaturevisorLogLevel level : List.of(FeaturevisorLogLevel.ERROR, FeaturevisorLogLevel.FATAL)) { + for (String input : List.of("{", "null", "{}", "{\"schemaVersion\":\"2\",\"revision\":\"x\",\"features\":{},\"segments\":[]}")) { + List diagnostics = new ArrayList<>(); + List revisions = new ArrayList<>(); + Featurevisor.FeaturevisorOptions options = new Featurevisor.FeaturevisorOptions() + .datafileString(input).logLevel(level).onDiagnostic(d -> diagnostics.add(d.getCode())) + .modules(List.of(new FeaturevisorModule("setup").setup(module -> revisions.add(module.getRevision())))); + com.featurevisor.sdk.FeaturevisorDiagnosticHandler handler = options.getOnDiagnostic(); + FeaturevisorOpenFeatureProvider provider = new FeaturevisorOpenFeatureProvider(options); + assertEquals(List.of("unknown"), revisions); + assertSame(handler, options.getOnDiagnostic()); + assertEquals(input, options.getDatafileString()); + assertEquals(dev.openfeature.sdk.ProviderState.ERROR, provider.getState(), input + " at " + level); + assertThrows(dev.openfeature.sdk.exceptions.ParseError.class, () -> provider.initialize(ImmutableContext.EMPTY)); + assertEquals(ErrorCode.PARSE_ERROR, provider.getBooleanEvaluation("checkout", false, ImmutableContext.EMPTY).getErrorCode()); + assertEquals(level == FeaturevisorLogLevel.FATAL ? 0 : 1, diagnostics.stream().filter("invalid_datafile"::equals).count()); + provider.getFeaturevisor().setDatafile(DATAFILE, true); + provider.initialize(ImmutableContext.EMPTY); + assertEquals(dev.openfeature.sdk.ProviderState.READY, provider.getState()); + provider.shutdown(); + } + } + FeaturevisorOpenFeatureProvider typed = new FeaturevisorOpenFeatureProvider(new Featurevisor.FeaturevisorOptions() + .datafile(new DatafileContent()).logLevel(FeaturevisorLogLevel.FATAL)); + assertEquals(ErrorCode.PARSE_ERROR, typed.getBooleanEvaluation("checkout", false, ImmutableContext.EMPTY).getErrorCode()); + typed.shutdown(); + } + + @Test void clientPreservesExplicitEmptyTargetingKey() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + JsonNode fixture = mapper.readTree(FeaturevisorOpenFeatureProviderTest.class.getResourceAsStream("/openfeature-provider.json")); + Featurevisor.FeaturevisorOptions options = new Featurevisor.FeaturevisorOptions() + .datafile(DatafileContent.fromJson(fixture.path("datafile").toString())).logLevel(FeaturevisorLogLevel.FATAL); + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.setProviderAndWait(new FeaturevisorOpenFeatureProvider(options)); + Map user = Map.of("userId", new Value("forced")); + assertTrue(api.getClient().getBooleanValue("targetOnly", false, new ImmutableContext(user))); + assertFalse(api.getClient().getBooleanValue("targetOnly", false, new ImmutableContext("", user))); + assertFalse(api.getClient().getBooleanValue("targetOnly", false, new ImmutableContext("other", user))); + assertEquals("forced", user.get("userId").asString()); + api.setProviderAndWait(new FeaturevisorOpenFeatureProvider( + new FeaturevisorOpenFeatureProvider.Options().featurevisorOptions(options).targetingKeyField("accountId"))); + Map account = Map.of("accountId", new Value("old")); + assertTrue(api.getClient().getBooleanValue("accountTargeted", false, new ImmutableContext("", account))); + assertEquals("old", account.get("accountId").asString()); + } + + @Test void sharedProviderConformance() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + JsonNode fixture = mapper.readTree(FeaturevisorOpenFeatureProviderTest.class.getResourceAsStream("/openfeature-provider.json")); + assertEquals("featurevisor.openfeature-provider", fixture.path("kind").asText()); + assertEquals(1, fixture.path("schemaVersion").asInt()); + assertEquals(16, fixture.path("cases").size()); + Featurevisor.FeaturevisorOptions options = new Featurevisor.FeaturevisorOptions() + .datafile(DatafileContent.fromJson(fixture.path("datafile").toString())).logLevel(FeaturevisorLogLevel.FATAL); + FeaturevisorOpenFeatureProvider provider = new FeaturevisorOpenFeatureProvider(options); + for (JsonNode item : fixture.path("cases")) { + String name = item.path("name").asText(); + JsonNode expected = item.path("expected"); + String source = item.path("context").toString(); + ImmutableContext context = corpusContext(item.path("context"), mapper); + ProviderEvaluation result; + String key = item.path("flagKey").asText(); + switch (item.path("type").asText()) { + case "boolean" -> result = provider.getBooleanEvaluation(key, item.path("default").asBoolean(), context); + case "string" -> result = provider.getStringEvaluation(key, item.path("default").asText(), context); + case "object" -> { + Map fallback = mapper.convertValue(item.path("default"), Map.class); + result = provider.getObjectEvaluation(key, new Value(Structure.mapToStructure(fallback)), context); + } + default -> throw new AssertionError("unknown case type " + name); + } + if ("object".equals(item.path("type").asText())) { + Map want = mapper.convertValue(expected.path("value"), Map.class); + assertEquals(want, ((Value) result.getValue()).asStructure().asObjectMap(), name); + } else if ("boolean".equals(item.path("type").asText())) { + assertEquals(expected.path("value").asBoolean(), result.getValue(), name); + } else { + assertEquals(expected.path("value").asText(), result.getValue(), name); + } + if (expected.has("reason")) assertEquals(expected.path("reason").asText(), result.getReason(), name); + assertEquals(expected.has("variant") ? expected.path("variant").asText() : null, result.getVariant(), name); + assertEquals(expected.has("errorCode") ? ErrorCode.valueOf(expected.path("errorCode").asText()) : null, result.getErrorCode(), name); + assertEquals(source, item.path("context").toString(), name); + } + for (JsonNode item : fixture.path("targetingCases")) { + String source = item.path("context").toString(); + assertEquals(item.path("expectedValue").asBoolean(), provider.getBooleanEvaluation("targetOnly", false, corpusContext(item.path("context"), mapper)).getValue(), item.path("name").asText()); + assertEquals(source, item.path("context").toString()); + } + provider.shutdown(); + FeaturevisorOpenFeatureProvider custom = new FeaturevisorOpenFeatureProvider( + new FeaturevisorOpenFeatureProvider.Options().featurevisorOptions(options).targetingKeyField("accountId")); + JsonNode item = fixture.path("customTargetingCase"); + assertEquals(item.path("expectedValue").asBoolean(), custom.getBooleanEvaluation(item.path("flagKey").asText(), false, corpusContext(item.path("context"), mapper)).getValue()); + custom.shutdown(); + } + + private static ImmutableContext corpusContext(JsonNode source, ObjectMapper mapper) { + Map fields = new HashMap<>(); + source.fields().forEachRemaining(entry -> { + if (!entry.getKey().equals("targetingKey")) fields.put(entry.getKey(), Value.objectToValue(mapper.convertValue(entry.getValue(), Object.class))); + }); + return new ImmutableContext(source.has("targetingKey") ? source.path("targetingKey").asText() : null, fields); + } + private static final String DATAFILE = """ {"schemaVersion":"2","revision":"openfeature-test","segments":{},"variables":{"welcome":{"hash":"welcome","type":"string","defaultValue":"Welcome","overrides":[{"key":"nl","conditions":{"attribute":"country","operator":"equals","value":"nl"},"value":"Welkom"}]}},"features":{"checkout":{ "bucketBy":"userId", @@ -30,8 +200,30 @@ private Featurevisor.FeaturevisorOptions options() throws Exception { return new Featurevisor.FeaturevisorOptions().datafile(DatafileContent.fromJson(DATAFILE)).logLevel(FeaturevisorLogLevel.FATAL); } + private FeaturevisorOpenFeatureProvider providerWithCount(Number value) throws Exception { + FeaturevisorModule module = new FeaturevisorModule("integer-value").afterEvaluation((evaluation, evaluateOptions) -> { + if ("count".equals(evaluation.getVariableKey())) evaluation.setVariableValue(value); + return evaluation; + }); + return new FeaturevisorOpenFeatureProvider(options().modules(List.of(module))); + } + @AfterEach void closeOpenFeature() { OpenFeatureAPI.getInstance().shutdown(); } + @Test void borrowedInstanceParseErrorRecoveryAndCleanup() throws Exception { + Featurevisor f = Featurevisor.createFeaturevisor(options()); + FeaturevisorOpenFeatureProvider provider = new FeaturevisorOpenFeatureProvider( + new FeaturevisorOpenFeatureProvider.Options().featurevisor(f)); + assertFalse(f.setDatafile("{", true)); + assertEquals(ErrorCode.PARSE_ERROR, provider.getBooleanEvaluation("checkout", false, null).getErrorCode()); + assertTrue(f.setDatafile(DATAFILE, true)); + assertNull(provider.getBooleanEvaluation("checkout", false, null).getErrorCode()); + provider.shutdown(); + assertFalse(f.setDatafile("{", true)); + assertNull(provider.getBooleanEvaluation("checkout", false, null).getErrorCode()); + f.close(); + } + @Test void resolvesEveryTypeAndMapsTargetingKey() throws Exception { FeaturevisorOpenFeatureProvider provider = new FeaturevisorOpenFeatureProvider(options()); ImmutableContext context = new ImmutableContext("forced-user"); @@ -46,6 +238,25 @@ private Featurevisor.FeaturevisorOptions options() throws Exception { assertTrue(provider.getObjectEvaluation("checkout:json", new Value(), context).getValue().asStructure().getValue("nested").asBoolean()); } + @Test void integerResolutionValidatesFiniteIntegralInt32RangeBeforeConversion() throws Exception { + ImmutableContext context = new ImmutableContext("forced-user"); + for (Number value : List.of(Integer.MIN_VALUE, Integer.MAX_VALUE, -2147483648.0, 2147483647.0)) { + ProviderEvaluation result = providerWithCount(value).getIntegerEvaluation("checkout:count", 17, context); + assertEquals(value.intValue(), result.getValue(), value.toString()); + assertNull(result.getErrorCode(), value.toString()); + } + + for (Number value : List.of(2147483648L, -2147483649L, 1.5, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, Double.NaN)) { + ProviderEvaluation result = providerWithCount(value).getIntegerEvaluation("checkout:count", 17, context); + assertEquals(17, result.getValue(), value.toString()); + assertEquals(ErrorCode.TYPE_MISMATCH, result.getErrorCode(), value.toString()); + assertEquals(Reason.ERROR.name(), result.getReason(), value.toString()); + assertEquals("checkout", result.getFlagMetadata().getString("featureKey")); + assertEquals("count", result.getFlagMetadata().getString("variableKey")); + assertEquals(Evaluation.REASON_FORCED, result.getFlagMetadata().getString("featurevisorReason")); + } + } + @Test void handlesErrorsCustomGrammarTrackingAndLifecycle() throws Exception { List tracked = new ArrayList<>(); FeaturevisorOpenFeatureProvider provider = new FeaturevisorOpenFeatureProvider( @@ -104,4 +315,38 @@ private Featurevisor.FeaturevisorOptions options() throws Exception { featurevisor.close(); assertTrue(closed.get()); } + + @Test void mapsUnmetVariableRequirementsToDisabled() throws Exception { + Featurevisor.FeaturevisorOptions options = new Featurevisor.FeaturevisorOptions() + .datafile(DatafileContent.fromJson(""" + {"schemaVersion":"2","revision":"requirements","segments":{},"variables":{ + "source":{"type":"boolean","defaultValue":false} + },"features":{ + "subject":{"bucketBy":"userId","conditions":[{"variable":"source","operator":"equals","value":true}], + "traffic":[{"key":"all","segments":"*","percentage":100000}]} + }} + """)) + .logLevel(FeaturevisorLogLevel.FATAL); + ProviderEvaluation result = new FeaturevisorOpenFeatureProvider(options) + .getBooleanEvaluation("subject", true, ImmutableContext.EMPTY); + assertFalse(result.getValue()); + assertEquals(Reason.DISABLED.name(), result.getReason()); + assertEquals("conditions_unmet", result.getFlagMetadata().getString("featurevisorReason")); + } + + @Test void mapsUnmetFeatureRequirementsToDisabled() throws Exception { + Featurevisor.FeaturevisorOptions options = new Featurevisor.FeaturevisorOptions() + .datafile(DatafileContent.fromJson(""" + {"schemaVersion":"2","revision":"requirements","segments":{},"features":{ + "subject":{"bucketBy":"userId","requiredFeatures":["missing"], + "traffic":[{"key":"all","segments":"*","percentage":100000}]} + }} + """)) + .logLevel(FeaturevisorLogLevel.FATAL); + ProviderEvaluation result = new FeaturevisorOpenFeatureProvider(options) + .getBooleanEvaluation("subject", true, ImmutableContext.EMPTY); + assertFalse(result.getValue()); + assertEquals(Reason.DISABLED.name(), result.getReason()); + assertEquals("conditions_unmet", result.getFlagMetadata().getString("featurevisorReason")); + } } diff --git a/featurevisor-openfeature/src/test/java/com/featurevisor/openfeature/JsonResolutionTest.java b/featurevisor-openfeature/src/test/java/com/featurevisor/openfeature/JsonResolutionTest.java new file mode 100644 index 0000000..1e0983b --- /dev/null +++ b/featurevisor-openfeature/src/test/java/com/featurevisor/openfeature/JsonResolutionTest.java @@ -0,0 +1,63 @@ +package com.featurevisor.openfeature; + +import com.featurevisor.sdk.DatafileContent; +import com.featurevisor.sdk.Featurevisor; +import com.featurevisor.sdk.FeaturevisorLogLevel; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Reason; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +final class JsonResolutionTest { + @Test void trailingDatafileTokensReportTheOriginalParseErrorAndRecover() { + String root = """ + {"schemaVersion":"4","revision":"accepted","segments":{},"features":{}, + "variables":{"value":{"type":"string","defaultValue":"ok"}}} + """; + FeaturevisorOpenFeatureProvider provider = new FeaturevisorOpenFeatureProvider( + new Featurevisor.FeaturevisorOptions().datafileString(root + " {}") + .logLevel(FeaturevisorLogLevel.FATAL)); + try { + ProviderEvaluation rejected = provider.getStringEvaluation("variable:value", "fallback", ImmutableContext.EMPTY); + assertEquals("fallback", rejected.getValue()); + assertEquals(ErrorCode.PARSE_ERROR, rejected.getErrorCode()); + assertEquals("Could not parse datafile", rejected.getErrorMessage()); + assertEquals("unknown", provider.getFeaturevisor().getRevision()); + assertTrue(provider.getFeaturevisor().setDatafile(root)); + ProviderEvaluation recovered = provider.getStringEvaluation("variable:value", "fallback", ImmutableContext.EMPTY); + assertNull(recovered.getErrorCode()); + assertEquals("ok", recovered.getValue()); + assertEquals("accepted", provider.getFeaturevisor().getRevision()); + } finally { provider.shutdown(); } + } + + @Test void rejectsTrailingJsonButNeverDecodesStringAliasesAgain() throws Exception { + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(DatafileContent.fromJson(""" + {"schemaVersion":"4","revision":"json-resolution","features":{},"segments":{},"variables":{ + "invalid":{"type":"json","defaultValue":"false true"}, + "nullAlias":{"type":"json","compose":"invalid"}, + "source":{"type":"json","defaultValue":"\\\"false\\\""}, + "alias":{"type":"json","compose":"source"}, + "chain":{"type":"json","compose":"alias"} + }} + """)).logLevel(FeaturevisorLogLevel.FATAL)); + FeaturevisorOpenFeatureProvider provider = new FeaturevisorOpenFeatureProvider(f); + try { + ProviderEvaluation invalid = provider.getBooleanEvaluation("variable:invalid", true, ImmutableContext.EMPTY); + assertTrue(invalid.getValue()); + assertEquals(ErrorCode.TYPE_MISMATCH, invalid.getErrorCode()); + ProviderEvaluation fallback = provider.getBooleanEvaluation("variable:nullAlias", true, ImmutableContext.EMPTY); + assertTrue(fallback.getValue()); + assertNull(fallback.getErrorCode()); + assertEquals(Reason.TARGETING_MATCH.name(), fallback.getReason()); + assertEquals("variable_composed", fallback.getFlagMetadata().getString("featurevisorReason")); + ProviderEvaluation alias = provider.getStringEvaluation("variable:chain", "fallback", ImmutableContext.EMPTY); + assertEquals("false", alias.getValue()); + assertNull(alias.getErrorCode()); + assertEquals(Reason.TARGETING_MATCH.name(), alias.getReason()); + } finally { provider.shutdown(); f.close(); } + } +} diff --git a/featurevisor-openfeature/src/test/resources/openfeature-provider.json b/featurevisor-openfeature/src/test/resources/openfeature-provider.json new file mode 100644 index 0000000..ef8c834 --- /dev/null +++ b/featurevisor-openfeature/src/test/resources/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/featurevisor-sdk/pom.xml b/featurevisor-sdk/pom.xml index b4d0d6b..a9b626d 100644 --- a/featurevisor-sdk/pom.xml +++ b/featurevisor-sdk/pom.xml @@ -38,13 +38,16 @@ com.fasterxml.jackson.core jackson-annotations + info.picocli picocli + true org.apache.commons commons-exec + true org.junit.jupiter diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/cli/CLI.java b/featurevisor-sdk/src/main/java/com/featurevisor/cli/CLI.java index 6209184..06c8a96 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/cli/CLI.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/cli/CLI.java @@ -13,6 +13,7 @@ import com.featurevisor.sdk.Evaluation; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.core.json.JsonWriteFeature; import com.fasterxml.jackson.core.type.TypeReference; import org.apache.commons.exec.DefaultExecutor; @@ -21,6 +22,8 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.File; +import java.nio.charset.StandardCharsets; import java.util.*; import com.featurevisor.sdk.FeaturevisorModule; @@ -34,10 +37,19 @@ @Command( name = "featurevisor", mixinStandardHelpOptions = true, - version = "4.1.0", - description = "Featurevisor Java Library CLI - Test runner, benchmark, and distribution assessment" + version = "5.0.0", + description = "Featurevisor Java Library CLI - Test runner, benchmark, and distribution assessment", + footer = { + "Node.js owns project parsing, test expansion and datafile building; Java evaluates the inputs.", + "Values accept --name=value or --name value. Repeat --target or --populate-uuid for multiple values.", + "Distribution also supports --variation and feature --variable as Java extensions." + } ) -public class CLI implements Runnable { +public class CLI implements java.util.concurrent.Callable { + @FunctionalInterface + interface NodeCommand { + String execute(String directory, String command) throws IOException; + } @Parameters( index = "0", @@ -45,7 +57,7 @@ public class CLI implements Runnable { ) private String command; - @Option(names = {"--assertionPattern"}, description = "Assertion pattern filter") + @Option(names = {"--assertion-pattern", "--assertionPattern"}, description = "Assertion pattern filter") private String assertionPattern; @Option(names = {"--context"}, description = "Context as JSON string") @@ -57,13 +69,13 @@ public class CLI implements Runnable { @Option(names = {"--feature"}, description = "Feature key") private String feature; - @Option(names = {"--keyPattern"}, description = "Key pattern filter") + @Option(names = {"--key-pattern", "--keyPattern"}, description = "Key pattern filter") private String keyPattern; @Option(names = {"-n", "--n"}, description = "Number of iterations") private Integer n = 1000; - @Option(names = {"--onlyFailures"}, description = "Show only failures") + @Option(names = {"--only-failures", "--onlyFailures"}, description = "Show only failures") private Boolean onlyFailures = false; @Option(names = {"--quiet"}, description = "Quiet mode") @@ -79,7 +91,7 @@ public class CLI implements Runnable { private Boolean verbose = false; @Option(names = {"--inflate"}, description = "Inflate number") - private Integer inflate = 0; + private Integer inflate; @Option(names = {"--with-scopes"}, description = "Legacy option accepted for compatibility and ignored") private Boolean withScopes = false; @@ -87,36 +99,109 @@ public class CLI implements Runnable { @Option(names = {"--with-tags"}, description = "Legacy option accepted for compatibility and ignored") private Boolean withTags = false; - @Option(names = {"--showDatafile"}, description = "Show datafile content for assertion") + @Option(names = {"--show-datafile", "--showDatafile"}, description = "Show datafile content for assertion") private Boolean showDatafile = false; @Option(names = {"--schemaVersion", "--schema-version"}, description = "Legacy option accepted for compatibility and ignored") private String schemaVersion; - @Option(names = {"--rootDirectoryPath"}, description = "Root directory path") private String rootDirectoryPath; - @Option(names = {"--projectDirectoryPath"}, description = "Project directory path") + @Option(names = {"--project-directory-path", "--projectDirectoryPath", "--root-directory-path", "--rootDirectoryPath"}, description = "Featurevisor project directory") private String projectDirectoryPath; - @Option(names = {"--populateUuid"}, description = "Populate UUID for specified keys") + @Option(names = {"--populate-uuid", "--populateUuid"}, arity = "1", description = "Context attribute to populate with a UUID; repeat for multiple attributes") private List populateUuid = new ArrayList<>(); - @Option(names = {"--target"}, description = "Target datafile; repeat for multiple targets") + @Option(names = {"--target"}, arity = "1", description = "Target datafile; repeat for multiple targets") private List targets = new ArrayList<>(); + @Option(names = {"--entity-type", "--entityType"}, description = "Test entity type: feature, segment, variable or scenario") + private String entityType; + + @Option(names = {"--set"}, description = "Project set") + private String set; + + @Option(names = {"--case"}, description = "Exact expanded test case ID") + private String caseId; + + @Option(names = {"--json"}, description = "Print a versioned JSON report") + private Boolean json = false; + + @Option(names = {"--pretty"}, description = "Pretty print JSON output") + private Boolean pretty = false; + + @Option(names = {"--coverage"}, description = "Report observed rule and override selections") + private Boolean coverage = false; + + @Option(names = {"--uncovered"}, description = "Report features and variables without tests") + private Boolean uncovered = false; + private String cwd; private ObjectMapper objectMapper; + private final NodeCommand nodeCommand; + private Map segmentHelperKeys = Collections.emptyMap(); + private final List> coverageSelections = new ArrayList<>(); + private String activeSet; + private String activeEnvironment; + private String activeTarget; public CLI() { + this(null); + } + + CLI(NodeCommand nodeCommand) { this.cwd = System.getProperty("user.dir"); this.objectMapper = new ObjectMapper(); + this.objectMapper.configure(JsonWriteFeature.ESCAPE_NON_ASCII.mappedFeature(), true); + this.nodeCommand = nodeCommand; // Configure ObjectMapper to handle null values properly this.objectMapper.setDefaultSetterInfo(com.fasterxml.jackson.annotation.JsonSetter.Value.forValueNulls(com.fasterxml.jackson.annotation.Nulls.AS_EMPTY)); } @Override - public void run() { + public Integer call() { + if (!Arrays.asList("test", "benchmark", "assess-distribution").contains(command)) { + System.err.println("Unknown command: " + command + ". Use --help for available commands."); + return 1; + } + if (n == null || n < 1) { + System.err.println("--n must be a positive integer"); + return 1; + } + if (entityType != null && !Arrays.asList("feature", "segment", "variable", "scenario").contains(entityType)) { + System.err.println("--entity-type must be feature, segment, variable or scenario"); + return 1; + } + for (String value : Arrays.asList(projectDirectoryPath, environment, feature, variable, set, caseId)) { + if (value != null && value.isEmpty()) { + System.err.println("Option values must not be empty"); + return 1; + } + } + if (targets.contains("") || populateUuid.contains("")) { + System.err.println("--target and --populate-uuid require nonempty values"); + return 1; + } + if (context != null) { + try { + JsonNode parsed = objectMapper.reader() + .with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .readTree(context); + if (parsed == null || !parsed.isObject()) throw new IOException("Expected an object"); + } catch (Exception error) { + System.err.println("--context must be a JSON object"); + return 1; + } + } + if (!"test".equals(command) && variation && variable != null) { + System.err.println("--variation and --variable cannot be combined"); + return 1; + } + if (inflate != null && inflate < 1) { + System.err.println("--inflate must be a positive integer"); + return 1; + } if (rootDirectoryPath == null && projectDirectoryPath != null) { rootDirectoryPath = projectDirectoryPath; } @@ -126,51 +211,99 @@ public void run() { switch (command) { case "test": - test(); - break; + return testReport(); case "benchmark": - benchmark(); - break; + return benchmark(); case "assess-distribution": - assessDistribution(); - break; + return assessDistribution(); default: - System.out.println("Learn more at https://featurevisor.com/docs/sdks/java/"); - break; + return 1; } } /** * Execute a command and return the output */ - private String executeCommand(String command) throws IOException { + String executeCommand(String command) throws IOException { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - PumpStreamHandler streamHandler = new PumpStreamHandler(outputStream); + ByteArrayOutputStream errorStream = new ByteArrayOutputStream(); + PumpStreamHandler streamHandler = new PumpStreamHandler(outputStream, errorStream); DefaultExecutor executor = new DefaultExecutor(); executor.setStreamHandler(streamHandler); try { executor.execute(org.apache.commons.exec.CommandLine.parse(command)); - return outputStream.toString(); + System.err.print(errorStream.toString(StandardCharsets.UTF_8)); + return outputStream.toString(StandardCharsets.UTF_8); } catch (ExecuteException e) { - throw new IOException("Command execution failed: " + e.getMessage(), e); + throw new IOException("Command execution failed: " + e.getMessage() + ": " + + (errorStream.toString(StandardCharsets.UTF_8) + "\n" + + outputStream.toString(StandardCharsets.UTF_8)).trim(), e); + } + } + + /** + * Runs 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, through its .cmd wrapper on Windows. + */ + static org.apache.commons.exec.CommandLine nodeCommandLine(String directory, String command) { + org.apache.commons.exec.CommandLine parsed = org.apache.commons.exec.CommandLine.parse(command); + String[] arguments = parsed.getArguments(); + if (!"npx".equals(parsed.getExecutable()) || arguments.length == 0 + || !"featurevisor".equals(arguments[0])) { + return parsed; } + String entry = findLocalCliEntry(directory); + boolean windows = System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT).startsWith("windows"); + org.apache.commons.exec.CommandLine resolved = new org.apache.commons.exec.CommandLine( + entry != null ? "node" : windows ? "npx.cmd" : "npx"); + resolved.addArgument(entry != null ? entry : "featurevisor", false); + for (int index = 1; index < arguments.length; index++) resolved.addArgument(arguments[index], false); + return resolved; } - private String executeCommandInDirectory(String directory, String command) throws IOException { + static String findLocalCliEntry(String directory) { + for (java.io.File current = new java.io.File(directory).getAbsoluteFile(); current != null; + current = current.getParentFile()) { + java.io.File candidate = new java.io.File(current, + "node_modules" + java.io.File.separator + "@featurevisor" + java.io.File.separator + + "cli" + java.io.File.separator + "bin.js"); + if (candidate.isFile()) return candidate.getPath(); + } + return null; + } + + String executeCommandInDirectory(String directory, String command) throws IOException { + return executeCommandInDirectory(directory, command, Collections.emptyList()); + } + + String executeCommandInDirectory(String directory, String command, List rawArguments) + throws IOException { + if (nodeCommand != null) { + String delegatedCommand = rawArguments.isEmpty() + ? command : command + " " + String.join(" ", rawArguments); + return nodeCommand.execute(directory, delegatedCommand); + } ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - PumpStreamHandler streamHandler = new PumpStreamHandler(outputStream); + ByteArrayOutputStream errorStream = new ByteArrayOutputStream(); + PumpStreamHandler streamHandler = new PumpStreamHandler(outputStream, errorStream); DefaultExecutor executor = new DefaultExecutor(); executor.setStreamHandler(streamHandler); executor.setWorkingDirectory(new java.io.File(directory)); try { - executor.execute(org.apache.commons.exec.CommandLine.parse(command)); - return outputStream.toString(); + org.apache.commons.exec.CommandLine commandLine = nodeCommandLine(directory, command); + for (String argument : rawArguments) commandLine.addArgument(argument, false); + executor.execute(commandLine); + System.err.print(errorStream.toString(StandardCharsets.UTF_8)); + return outputStream.toString(StandardCharsets.UTF_8); } catch (ExecuteException e) { - throw new IOException("Command execution failed: " + e.getMessage(), e); + throw new IOException("Command execution failed: " + e.getMessage() + ": " + + (errorStream.toString(StandardCharsets.UTF_8) + "\n" + + outputStream.toString(StandardCharsets.UTF_8)).trim(), e); } } @@ -178,7 +311,7 @@ private String executeCommandInDirectory(String directory, String command) throw * Get config from featurevisor project */ private Map getConfig(String featurevisorProjectPath) throws IOException { - System.out.println("Getting config..."); + progress("Getting config..."); String configOutput = executeCommandInDirectory(featurevisorProjectPath, "npx featurevisor config --json"); return objectMapper.readValue(configOutput, new TypeReference>() {}); } @@ -187,8 +320,8 @@ private Map getConfig(String featurevisorProjectPath) throws IOE * Get segments from featurevisor project */ private Map getSegments(String featurevisorProjectPath) throws IOException { - System.out.println("Getting segments..."); - String segmentsOutput = executeCommandInDirectory(featurevisorProjectPath, "npx featurevisor list --segments --json"); + progress("Getting segments..."); + String segmentsOutput = executeCommandInDirectory(featurevisorProjectPath, "npx featurevisor list --segments --json", selectionArguments()); List segments = objectMapper.readValue(segmentsOutput, new TypeReference>() {}); Map segmentsByKey = new HashMap<>(); for (Segment segment : segments) { @@ -198,11 +331,11 @@ private Map getSegments(String featurevisorProjectPath) throws } String getEnvironmentKey(String environment) { - return environment == null ? "__no_environment__" : environment; + return targetDatafileCacheKey(environment, null); } String targetDatafileCacheKey(String environment, String target) { - return (environment == null ? "false" : environment) + "-target-" + target; + return objectMapper.createArrayNode().add(environment).add(target).toString(); } String selectDatafileKeyForAssertion(Map assertion, Map datafileCache) { @@ -231,19 +364,19 @@ private DatafileContent buildDatafile( String environment, String target ) throws IOException { - StringBuilder command = new StringBuilder("npx featurevisor build --json"); - if (environment != null) { - command.append(" --environment=").append(environment); + List arguments = new ArrayList<>(Arrays.asList("build", "--json")); + if (environment != null && !environment.isEmpty()) { + arguments.add("--environment=" + environment); } if (target != null) { - command.append(" --target=").append(target); + arguments.add("--target=" + target); } - if (inflate != null && inflate > 0) { - command.append(" --inflate=").append(inflate); + if (inflate != null) { + arguments.add("--inflate=" + inflate); } - - String datafileOutput = executeCommandInDirectory(featurevisorProjectPath, command.toString()); - return parseDatafileContent(datafileOutput, command.toString()); + if (set != null) arguments.add("--set=" + set); + String datafileOutput = executeCommandInDirectory(featurevisorProjectPath, "npx featurevisor", arguments); + return parseDatafileContent(datafileOutput, String.join(" ", arguments)); } private Map buildBaseDatafiles( @@ -252,7 +385,7 @@ private Map buildBaseDatafiles( ) throws IOException { Map datafilesByEnvironment = new HashMap<>(); for (String env : environments) { - System.out.println("Building datafile for environment: " + (env == null ? "default" : env) + "..."); + progress("Building datafile for environment: " + (env == null ? "default" : env) + "..."); datafilesByEnvironment.put(getEnvironmentKey(env), buildDatafile(featurevisorProjectPath, env, null)); } return datafilesByEnvironment; @@ -266,7 +399,7 @@ private Map buildTargetDatafiles( Map datafilesByTarget = new HashMap<>(); for (String env : environments) { for (String target : targets) { - System.out.println("Building datafile for target: " + target + " environment: " + (env == null ? "default" : env) + "..."); + progress("Building datafile for target: " + target + " environment: " + (env == null ? "default" : env) + "..."); datafilesByTarget.put(targetDatafileCacheKey(env, target), buildDatafile(featurevisorProjectPath, env, target)); } } @@ -291,8 +424,8 @@ private List getEnvironmentList(Map config) { } private List getTargets(String featurevisorProjectPath) throws IOException { - System.out.println("Getting targets..."); - String targetsOutput = executeCommandInDirectory(featurevisorProjectPath, "npx featurevisor list --targets --json"); + progress("Getting targets..."); + String targetsOutput = executeCommandInDirectory(featurevisorProjectPath, "npx featurevisor list --targets --json", selectionArguments()); JsonNode targetsNode = objectMapper.readTree(targetsOutput); List targets = new ArrayList<>(); @@ -340,51 +473,97 @@ private String generateUuid() { * Get tests from featurevisor project */ private List> getTests(String featurevisorProjectPath) throws IOException { - StringBuilder testsSuffix = new StringBuilder(); + return getTests(featurevisorProjectPath, true); + } + + private List> getTests(String featurevisorProjectPath, boolean includeCase) throws IOException { + List rawArguments = new ArrayList<>(); if (keyPattern != null) { - testsSuffix.append(" --keyPattern=").append(keyPattern); + rawArguments.add("--keyPattern=" + keyPattern); } if (assertionPattern != null) { - testsSuffix.append(" --assertionPattern=").append(assertionPattern); + rawArguments.add("--assertionPattern=" + assertionPattern); + } + if (entityType != null) { + rawArguments.add("--entityType=" + entityType); } + String command = "npx featurevisor list --tests --execution-protocol --apply-matrix --execution-metadata --json"; + rawArguments.addAll(selectionArguments()); + if (includeCase && caseId != null) rawArguments.add("--case=" + caseId); + String testsOutput = executeCommandInDirectory(featurevisorProjectPath, command, rawArguments); + JsonNode document = objectMapper.readTree(testsOutput); + if (!document.isObject()) throw new IOException("Test execution protocol output must be an object"); + if (!document.has("kind") || !document.get("kind").isTextual() + || !"featurevisor.test-execution".equals(document.get("kind").textValue())) { + throw new IOException("Test execution protocol kind must be featurevisor.test-execution"); + } + JsonNode version = document.get("schemaVersion"); + if (version == null || !version.isNumber() || version.doubleValue() != 1.0) { + throw new IOException("Test execution protocol schemaVersion must be the number 1"); + } + JsonNode tests = document.get("tests"); + if (tests == null || !tests.isArray()) { + throw new IOException("Test execution protocol tests must be an array of objects"); + } + for (JsonNode test : tests) { + if (!test.isObject()) throw new IOException("Test execution protocol tests must be an array of objects"); + } + return objectMapper.convertValue(tests, new TypeReference>>() {}); + } - String testsOutput = executeCommandInDirectory(featurevisorProjectPath, "npx featurevisor list --tests --applyMatrix --json" + testsSuffix); - return objectMapper.readValue(testsOutput, new TypeReference>>() {}); + private void progress(String message) { + if (!Boolean.TRUE.equals(json)) System.out.println(message); + } + + private List selectionArguments() { + return set == null ? Collections.emptyList() : Collections.singletonList("--set=" + set); } /** * Test a feature */ private TestResult testFeature(Map assertion, String featureKey, Object f, FeaturevisorLogLevel level) { + return testFeature(assertion, featureKey, f, level, Collections.emptyList()); + } + + private TestResult testFeature(Map assertion, String featureKey, Object f, + FeaturevisorLogLevel level, List prefix) { @SuppressWarnings("unchecked") Map context = (Map) assertion.getOrDefault("context", new HashMap<>()); @SuppressWarnings("unchecked") - Map sticky = (Map) assertion.getOrDefault("sticky", new HashMap<>()); + Map sticky = (Map) assertion.getOrDefault("stickyFeatures", new HashMap<>()); + @SuppressWarnings("unchecked") + Map stickyVariables = + (Map) assertion.getOrDefault("stickyVariables", new HashMap<>()); // Update the SDK instance context and sticky values for this assertion if (f instanceof Featurevisor) { ((Featurevisor) f).setContext(context, true); ((Featurevisor) f).setStickyFeatures(sticky, true); + ((Featurevisor) f).setStickyVariables(stickyVariables, true); } else if (f instanceof com.featurevisor.sdk.ChildInstance) { ((com.featurevisor.sdk.ChildInstance) f).setContext(context, true); ((com.featurevisor.sdk.ChildInstance) f).setStickyFeatures(sticky, true); + ((com.featurevisor.sdk.ChildInstance) f).setStickyVariables(stickyVariables, true); } boolean hasError = false; StringBuilder errors = new StringBuilder(); + List reportErrors = new ArrayList<>(); long startTime = System.nanoTime(); - // Test expectedToBeEnabled if (assertion.containsKey("expectedToBeEnabled")) { boolean isEnabled = isEnabled(f, featureKey, context); boolean expected = (Boolean) assertion.get("expectedToBeEnabled"); if (isEnabled != expected) { hasError = true; - errors.append(" ✘ expectedToBeEnabled: expected ").append(expected).append(" but received ").append(isEnabled).append("\n"); + String message = "expectedToBeEnabled: expected " + expected + " but received " + isEnabled; + errors.append(" ✘ ").append(message).append("\n"); + reportErrors.add(new TestError(message, "flag", appendPath(prefix, "expectedToBeEnabled"), + expected, isEnabled, childDetails(prefix))); } } - // Test expectedVariation if (assertion.containsKey("expectedVariation")) { Featurevisor.OverrideOptions options = new Featurevisor.OverrideOptions(); if (assertion.containsKey("defaultVariationValue")) { @@ -395,11 +574,14 @@ private TestResult testFeature(Map assertion, String featureKey, Object expected = assertion.get("expectedVariation"); if (!Objects.equals(variation, expected)) { hasError = true; - errors.append(" ✘ expectedVariation: expected ").append(expected).append(" but received ").append(variation).append("\n"); + String message = "expectedVariation: expected " + formatTestValue(expected) + + " but received " + formatTestValue(variation); + errors.append(" ✘ ").append(message).append("\n"); + reportErrors.add(new TestError(message, "variation", appendPath(prefix, "expectedVariation"), + expected, variation, childDetails(prefix))); } } - // Test expectedVariables if (assertion.containsKey("expectedVariables")) { @SuppressWarnings("unchecked") Map expectedVariables = (Map) assertion.get("expectedVariables"); @@ -428,56 +610,70 @@ private TestResult testFeature(Map assertion, String featureKey, Object actualValue = getVariable(f, featureKey, variableKey, context, options); if (!Objects.equals(actualValue, expectedValue)) { hasError = true; - errors.append(" ✘ expectedVariables.").append(variableKey).append(": expected ").append(expectedValue).append(" but received ").append(actualValue).append("\n"); + String message = "expectedVariables." + variableKey + ": expected " + + formatTestValue(expectedValue) + " but received " + formatTestValue(actualValue); + errors.append(" ✘ ").append(message).append("\n"); + Map details = childDetails(prefix); + details.put("variableKey", variableKey); + reportErrors.add(new TestError(message, "variable", + appendPath(prefix, "expectedVariables", variableKey), expectedValue, actualValue, details)); } } } - // Test expectedEvaluations if (assertion.containsKey("expectedEvaluations")) { @SuppressWarnings("unchecked") Map expectedEvaluations = (Map) assertion.get("expectedEvaluations"); - if (expectedEvaluations.containsKey("flag")) { - @SuppressWarnings("unchecked") - Map flagEvaluation = (Map) expectedEvaluations.get("flag"); - com.featurevisor.sdk.Evaluation actualEvaluation = evaluateFlag(f, featureKey, context); - - if (actualEvaluation != null) { - for (Map.Entry entry : flagEvaluation.entrySet()) { - String key = entry.getKey(); - Object expectedValue = entry.getValue(); - Object actualValue = getEvaluationValue(actualEvaluation, key); - if (!Objects.equals(actualValue, expectedValue)) { - hasError = true; - errors.append(" ✘ expectedEvaluations.flag.").append(key).append(": expected ").append(expectedValue).append(" but received ").append(actualValue).append("\n"); + if (expectedEvaluations.containsKey("flag")) { + @SuppressWarnings("unchecked") + Map flagEvaluation = (Map) expectedEvaluations.get("flag"); + com.featurevisor.sdk.Evaluation actualEvaluation = evaluateFlag(f, featureKey, context); + if (actualEvaluation != null) { + for (Map.Entry entry : flagEvaluation.entrySet()) { + Object actualValue = getEvaluationValue(actualEvaluation, entry.getKey()); + if (!Objects.equals(actualValue, entry.getValue())) { + hasError = true; + String message = "expectedEvaluations.flag." + entry.getKey() + ": expected " + + formatTestValue(entry.getValue()) + " but received " + formatTestValue(actualValue); + errors.append(" ✘ ").append(message).append("\n"); + Map details = childDetails(prefix); + details.put("evaluationType", "flag"); + details.put("evaluationKey", entry.getKey()); + reportErrors.add(new TestError(message, "evaluation", + appendPath(prefix, "expectedEvaluations", "flag", entry.getKey()), + entry.getValue(), actualValue, details)); + } } } } - } - - if (expectedEvaluations.containsKey("variation")) { - @SuppressWarnings("unchecked") - Map variationEvaluation = (Map) expectedEvaluations.get("variation"); - Featurevisor.OverrideOptions options = new Featurevisor.OverrideOptions(); - if (assertion.containsKey("defaultVariationValue")) { - options.setDefaultVariationValue(assertion.get("defaultVariationValue").toString()); - } - - com.featurevisor.sdk.Evaluation actualEvaluation = evaluateVariation(f, featureKey, context, options); - if (actualEvaluation != null) { - for (Map.Entry entry : variationEvaluation.entrySet()) { - String key = entry.getKey(); - Object expectedValue = entry.getValue(); - Object actualValue = getEvaluationValue(actualEvaluation, key); - if (!Objects.equals(actualValue, expectedValue)) { - hasError = true; - errors.append(" ✘ expectedEvaluations.variation.").append(key).append(": expected ").append(expectedValue).append(" but received ").append(actualValue).append("\n"); + if (expectedEvaluations.containsKey("variation")) { + @SuppressWarnings("unchecked") + Map variationEvaluation = (Map) expectedEvaluations.get("variation"); + Featurevisor.OverrideOptions options = new Featurevisor.OverrideOptions(); + if (assertion.containsKey("defaultVariationValue")) { + options.setDefaultVariationValue(assertion.get("defaultVariationValue").toString()); + } + com.featurevisor.sdk.Evaluation actualEvaluation = evaluateVariation(f, featureKey, context, options); + if (actualEvaluation != null) { + for (Map.Entry entry : variationEvaluation.entrySet()) { + Object actualValue = getEvaluationValue(actualEvaluation, entry.getKey()); + if (!Objects.equals(actualValue, entry.getValue())) { + hasError = true; + String message = "expectedEvaluations.variation." + entry.getKey() + ": expected " + + formatTestValue(entry.getValue()) + " but received " + formatTestValue(actualValue); + errors.append(" ✘ ").append(message).append("\n"); + Map details = childDetails(prefix); + details.put("evaluationType", "variation"); + details.put("evaluationKey", entry.getKey()); + reportErrors.add(new TestError(message, "evaluation", + appendPath(prefix, "expectedEvaluations", "variation", entry.getKey()), + entry.getValue(), actualValue, details)); + } } } } - } if (expectedEvaluations.containsKey("variables")) { @SuppressWarnings("unchecked") @@ -499,12 +695,20 @@ private TestResult testFeature(Map assertion, String featureKey, if (actualEvaluation != null) { for (Map.Entry evalEntry : expectedEvaluation.entrySet()) { - String key = evalEntry.getKey(); - Object expectedValue = evalEntry.getValue(); - Object actualValue = getEvaluationValue(actualEvaluation, key); - if (!Objects.equals(actualValue, expectedValue)) { + Object actualValue = getEvaluationValue(actualEvaluation, evalEntry.getKey()); + if (!Objects.equals(actualValue, evalEntry.getValue())) { hasError = true; - errors.append(" ✘ expectedEvaluations.variables.").append(variableKey).append(".").append(key).append(": expected ").append(expectedValue).append(" but received ").append(actualValue).append("\n"); + String message = "expectedEvaluations.variables." + variableKey + "." + + evalEntry.getKey() + ": expected " + formatTestValue(evalEntry.getValue()) + + " but received " + formatTestValue(actualValue); + errors.append(" ✘ ").append(message).append("\n"); + Map details = childDetails(prefix); + details.put("variableKey", variableKey); + details.put("evaluationType", "variable"); + details.put("evaluationKey", evalEntry.getKey()); + reportErrors.add(new TestError(message, "evaluation", + appendPath(prefix, "expectedEvaluations", "variables", variableKey, evalEntry.getKey()), + evalEntry.getValue(), actualValue, details)); } } } @@ -514,17 +718,18 @@ private TestResult testFeature(Map assertion, String featureKey, double duration = (System.nanoTime() - startTime) / 1_000_000.0; // Convert to milliseconds - // Test children if (assertion.containsKey("children")) { @SuppressWarnings("unchecked") List> children = (List>) assertion.get("children"); - for (Map child : children) { + for (int childIndex = 0; childIndex < children.size(); childIndex++) { + Map child = children.get(childIndex); @SuppressWarnings("unchecked") Map childContext = (Map) child.getOrDefault("context", new HashMap<>()); com.featurevisor.sdk.ChildInstance childF = spawn(f, childContext); TestResult childResult; try { - childResult = testFeature(child, featureKey, childF, level); + childResult = testFeature(child, featureKey, childF, level, + appendPath(prefix, "children", childIndex)); } finally { childF.close(); } @@ -534,13 +739,16 @@ private TestResult testFeature(Map assertion, String featureKey, if (childResult.hasError) { errors.append(childResult.errors); } + reportErrors.addAll(childResult.reportErrors); } } - return new TestResult(hasError, errors.toString(), duration); + return new TestResult(hasError, errors.toString(), duration, reportErrors); } - private TestResult testGlobalVariable(Map assertion, String variableKey, Featurevisor f) { + private TestResult testGlobalVariable(Map assertion, String variableKey, Featurevisor f, + DatafileContent datafile, List diagnostics, + Map segmentDefinitions) { @SuppressWarnings("unchecked") Map context = (Map) assertion.getOrDefault("context", new HashMap<>()); f.setContext(context, true); Featurevisor.OverrideOptions options = new Featurevisor.OverrideOptions(); @@ -549,10 +757,15 @@ private TestResult testGlobalVariable(Map assertion, String vari Evaluation evaluation = f.evaluateVariable(variableKey, new HashMap<>(), options); boolean hasError = false; StringBuilder errors = new StringBuilder(); + List reportErrors = new ArrayList<>(); if (assertion.containsKey("expectedValue") && !Objects.equals(assertion.get("expectedValue"), evaluation.getVariableValue())) { hasError = true; - errors.append(" ✘ expectedValue: expected ").append(formatTestValue(assertion.get("expectedValue"))) - .append(" but received ").append(formatTestValue(evaluation.getVariableValue())).append("\n"); + String message = "expectedValue: expected " + formatTestValue(assertion.get("expectedValue")) + + " but received " + formatTestValue(evaluation.getVariableValue()); + errors.append(" ✘ ").append(message).append("\n"); + reportErrors.add(expectationError(message, "variable", Arrays.asList("expectedValue"), + assertion.get("expectedValue"), evaluation.getVariableValue(), + linkedMap("variableKey", variableKey))); } if (assertion.containsKey("expectedEvaluation")) { @SuppressWarnings("unchecked") Map expected = (Map) assertion.get("expectedEvaluation"); @@ -562,6 +775,21 @@ private TestResult testGlobalVariable(Map assertion, String vari hasError = true; errors.append(" ✘ expectedEvaluation.").append(entry.getKey()).append(": expected ") .append(formatTestValue(entry.getValue())).append(" but received ").append(formatTestValue(actual)).append("\n"); + reportErrors.add(expectationError("expectedEvaluation." + entry.getKey() + ": expected " + + formatTestValue(entry.getValue()) + " but received " + formatTestValue(actual), + "evaluation", Arrays.asList("expectedEvaluation", entry.getKey()), entry.getValue(), actual, + linkedMap("evaluationType", "variable", "evaluationKey", entry.getKey()))); + } + } + } + if (assertion.containsKey("checks")) { + List checkErrors = testCheckFailures(f, assertion, variableKey, datafile, + diagnostics, segmentDefinitions, context); + if (!checkErrors.isEmpty()) { + hasError = true; + for (VariableChecks.CheckFailure error : checkErrors) { + errors.append(" ✘ ").append(error.message).append("\n"); + reportErrors.add(checkError(error, null)); } } } @@ -579,29 +807,41 @@ private TestResult testGlobalVariable(Map assertion, String vari .stickyVariables(childStickyVariables); com.featurevisor.sdk.ChildInstance child = f.spawn(childContext, spawnOptions); try { - TestResult childResult = testGlobalVariableChild(childAssertion, variableKey, child, childIndex); + TestResult childResult = testGlobalVariableChild(childAssertion, variableKey, child, childIndex, + datafile, diagnostics, segmentDefinitions, mergeContexts(context, childContext)); hasError = hasError || childResult.hasError; errors.append(childResult.errors); + reportErrors.addAll(childResult.reportErrors); } finally { child.close(); } } } - return new TestResult(hasError, errors.toString(), (System.nanoTime() - startTime) / 1_000_000.0); + return new TestResult(hasError, errors.toString(), (System.nanoTime() - startTime) / 1_000_000.0, reportErrors); } - private TestResult testGlobalVariableChild(Map assertion, String variableKey, com.featurevisor.sdk.ChildInstance child, int childIndex) { + private TestResult testGlobalVariableChild(Map assertion, String variableKey, + com.featurevisor.sdk.ChildInstance child, int childIndex, DatafileContent datafile, + List diagnostics, Map segmentDefinitions, + Map effectiveContext) { Featurevisor.OverrideOptions options = new Featurevisor.OverrideOptions(); if (assertion.containsKey("defaultVariableValue")) options.setDefaultVariableValue(assertion.get("defaultVariableValue")); Evaluation evaluation = child.evaluateVariable(variableKey, new HashMap<>(), options); boolean hasError = false; StringBuilder errors = new StringBuilder(); + List reportErrors = new ArrayList<>(); String prefix = "children[" + childIndex + "]."; if (assertion.containsKey("expectedValue") && !Objects.equals(assertion.get("expectedValue"), evaluation.getVariableValue())) { hasError = true; errors.append(" ✘ ").append(prefix).append("expectedValue: expected ") .append(formatTestValue(assertion.get("expectedValue"))).append(" but received ") .append(formatTestValue(evaluation.getVariableValue())).append("\n"); + reportErrors.add(expectationError(prefix + "expectedValue: expected " + + formatTestValue(assertion.get("expectedValue")) + " but received " + + formatTestValue(evaluation.getVariableValue()), + "variable", Arrays.asList("children", childIndex, "expectedValue"), + assertion.get("expectedValue"), evaluation.getVariableValue(), + linkedMap("variableKey", variableKey, "childIndex", childIndex))); } if (assertion.containsKey("expectedEvaluation")) { @SuppressWarnings("unchecked") Map expected = (Map) assertion.get("expectedEvaluation"); @@ -612,10 +852,257 @@ private TestResult testGlobalVariableChild(Map assertion, String errors.append(" ✘ ").append(prefix).append("expectedEvaluation.").append(entry.getKey()) .append(": expected ").append(formatTestValue(entry.getValue())) .append(" but received ").append(formatTestValue(actual)).append("\n"); + reportErrors.add(expectationError(prefix + "expectedEvaluation." + entry.getKey() + + ": expected " + formatTestValue(entry.getValue()) + " but received " + formatTestValue(actual), + "evaluation", Arrays.asList("children", childIndex, "expectedEvaluation", entry.getKey()), + entry.getValue(), actual, + linkedMap("evaluationType", "variable", "evaluationKey", entry.getKey(), "childIndex", childIndex))); } } } - return new TestResult(hasError, errors.toString(), 0); + if (assertion.containsKey("checks")) { + List checkErrors = testCheckFailures(child, assertion, variableKey, datafile, + diagnostics, segmentDefinitions, effectiveContext); + if (!checkErrors.isEmpty()) { + hasError = true; + for (VariableChecks.CheckFailure error : checkErrors) { + errors.append(" ✘ ").append(prefix).append(error.message).append("\n"); + reportErrors.add(checkError(error, childIndex)); + } + } + } + return new TestResult(hasError, errors.toString(), 0, reportErrors); + } + + private TestResult testScenario(Map assertion, Featurevisor f, DatafileContent datafile, + List diagnostics, Map segmentDefinitions) { + long startTime = System.nanoTime(); + @SuppressWarnings("unchecked") Map rootContext = + (Map) assertion.getOrDefault("context", new HashMap<>()); + boolean hasChecks = assertion.containsKey("checks"); + boolean hasChildren = assertion.get("children") instanceof List && !((List) assertion.get("children")).isEmpty(); + List failures = hasChecks + ? testCheckFailures(f, assertion, null, datafile, diagnostics, segmentDefinitions, rootContext) + : new ArrayList<>(); + StringBuilder errors = new StringBuilder(); + List reportErrors = new ArrayList<>(); + if (!hasChecks && !hasChildren) { + String message = "scenario assertion requires checks or children"; + errors.append(" ✘ ").append(message).append("\n"); + reportErrors.add(expectationError(message, "check", Collections.emptyList(), + "checks or children", null, null)); + } + for (VariableChecks.CheckFailure failure : failures) { + errors.append(" ✘ ").append(failure.message).append("\n"); + reportErrors.add(checkError(failure, null)); + } + if (hasChildren) { + @SuppressWarnings("unchecked") List> children = (List>) assertion.get("children"); + for (int index = 0; index < children.size(); index++) { + Map childAssertion = children.get(index); + @SuppressWarnings("unchecked") Map childContext = (Map) childAssertion.getOrDefault("context", new HashMap<>()); + @SuppressWarnings("unchecked") Map stickyFeatures = (Map) childAssertion.get("stickyFeatures"); + @SuppressWarnings("unchecked") Map stickyVariables = (Map) childAssertion.get("stickyVariables"); + com.featurevisor.sdk.ChildInstance child = f.spawn(childContext, new Featurevisor.SpawnOptions() + .stickyFeatures(stickyFeatures == null ? new HashMap<>() : stickyFeatures) + .stickyVariables(stickyVariables == null ? new HashMap<>() : stickyVariables)); + try { + List childFailures = childAssertion.containsKey("checks") + ? testCheckFailures(child, childAssertion, null, datafile, diagnostics, segmentDefinitions, + mergeContexts(rootContext, childContext)) + : new ArrayList<>(); + if (!childAssertion.containsKey("checks")) { + String message = "children[" + index + "].child requires checks"; + errors.append(" ✘ ").append(message).append("\n"); + reportErrors.add(expectationError(message, "check", Arrays.asList("children", index), + "checks", null, linkedMap("childIndex", index))); + } + for (VariableChecks.CheckFailure failure : childFailures) { + errors.append(" ✘ children[").append(index).append("].").append(failure.message).append("\n"); + reportErrors.add(checkError(failure, index)); + } + failures.addAll(childFailures); + } finally { child.close(); } + } + } + boolean hasError = !failures.isEmpty() || !reportErrors.isEmpty(); + return new TestResult(hasError, errors.toString(), (System.nanoTime() - startTime) / 1_000_000.0, reportErrors); + } + + private List testChecks(Object sdk, Map assertion, String ownerVariable, + DatafileContent datafile, List diagnostics) { + List messages = new ArrayList<>(); + @SuppressWarnings("unchecked") Map context = + (Map) assertion.getOrDefault("context", new HashMap<>()); + for (VariableChecks.CheckFailure failure : testCheckFailures(sdk, assertion, ownerVariable, datafile, + diagnostics, Collections.emptyMap(), context)) { + messages.add(failure.message); + } + return messages; + } + + private List testCheckFailures(Object sdk, Map assertion, + String ownerVariable, DatafileContent datafile, + List diagnostics, Map segmentDefinitions, + Map effectiveContext) { + return new VariableChecks(reference -> { + String featureKey = reference.get("feature") instanceof String ? (String) reference.get("feature") : null; + String variableKey = reference.get("variable") instanceof String ? (String) reference.get("variable") : ownerVariable; + if (variableKey == null || variableKey.isEmpty()) return VariableChecks.Resolution.error("variable is required"); + if (featureKey != null && !reference.containsKey("variable")) { + return VariableChecks.Resolution.error("feature references require an explicit variable"); + } + if (!reference.containsKey("variable") && ownerVariable != null) reference.put("variable", ownerVariable); + @SuppressWarnings("unchecked") Map context = (Map) assertion.getOrDefault("context", new HashMap<>()); + try { + diagnostics.clear(); + Evaluation evaluation; + Object value; + if (featureKey == null) { + if (datafile.getVariables() == null || !datafile.getVariables().containsKey(variableKey)) { + return VariableChecks.Resolution.error("global variable \"" + variableKey + "\" is absent from the selected datafile"); + } + evaluation = evaluateGlobalVariable(sdk, variableKey, context); + if ("error".equals(evaluation.getReason()) || evaluation.getError() != null) { + return VariableChecks.Resolution.error("global variable \"" + variableKey + "\" evaluation failed: " + + (evaluation.getError() == null ? evaluation.getReason() : evaluation.getError().getMessage())); + } + value = getGlobalVariable(sdk, variableKey, context); + } else { + com.featurevisor.sdk.Feature featureData = datafile.getFeature(featureKey); + if (featureData == null || featureData.getVariablesSchema() == null + || !featureData.getVariablesSchema().containsKey(variableKey)) { + return VariableChecks.Resolution.error("feature variable \"" + featureKey + "." + variableKey + + "\" is absent from the selected datafile"); + } + evaluation = evaluateVariable(sdk, featureKey, variableKey, context, new Featurevisor.OverrideOptions()); + if ("error".equals(evaluation.getReason()) || evaluation.getError() != null) { + return VariableChecks.Resolution.error("feature variable \"" + featureKey + "." + variableKey + + "\" evaluation failed: " + (evaluation.getError() == null ? evaluation.getReason() : evaluation.getError().getMessage())); + } + value = getVariable(sdk, featureKey, variableKey, context, new Featurevisor.OverrideOptions()); + } + for (com.featurevisor.sdk.FeaturevisorDiagnostic diagnostic : diagnostics) { + if (diagnostic.getLevel() == FeaturevisorLogLevel.ERROR) { + return VariableChecks.Resolution.error("getter failed: " + diagnostic.getCode() + ": " + diagnostic.getMessage()); + } + } + return VariableChecks.Resolution.value(value); + } catch (Exception error) { + return VariableChecks.Resolution.error("getter failed: " + error.getMessage()); + } + }, (featureKey, getter) -> { + if (datafile.getFeature(featureKey) == null) { + return VariableChecks.Resolution.error("feature is absent from the selected datafile"); + } + @SuppressWarnings("unchecked") Map context = + (Map) assertion.getOrDefault("context", new HashMap<>()); + diagnostics.clear(); + try { + Object value = getter.equals("enabled") + ? isEnabled(sdk, featureKey, context) + : getVariation( + sdk, featureKey, context, new Featurevisor.OverrideOptions() + ); + String diagnostic = getterDiagnosticError(diagnostics); + return diagnostic == null + ? VariableChecks.Resolution.value(value) + : VariableChecks.Resolution.error(diagnostic); + } catch (Exception error) { + return VariableChecks.Resolution.error(error.getMessage()); + } + }, segmentKey -> { + String error = SegmentEvaluations.validate(segmentKey, datafile, segmentDefinitions); + if (error != null) return VariableChecks.Resolution.error(error); + String helper = segmentHelperKeys.get(segmentKey); + if (helper == null) return VariableChecks.Resolution.error("segment definition is missing"); + diagnostics.clear(); + Evaluation evaluation = sdk instanceof Featurevisor + ? ((Featurevisor) sdk).evaluateFlag(helper, effectiveContext) + : ((com.featurevisor.sdk.ChildInstance) sdk).evaluateFlag(helper, effectiveContext); + String diagnostic = getterDiagnosticError(diagnostics); + if (evaluation.getError() != null || diagnostic != null) { + return VariableChecks.Resolution.error(diagnostic != null ? diagnostic : evaluation.getError().getMessage()); + } + return VariableChecks.Resolution.value(Boolean.TRUE.equals(evaluation.getEnabled())); + }, segmentKey -> SegmentEvaluations.validate(segmentKey, datafile, segmentDefinitions)) + .evaluateStructured(assertion.get("checks")); + } + + private Map mergeContexts(Map root, Map child) { + Map merged = new LinkedHashMap<>(root); + merged.putAll(child); + return merged; + } + + private Map segmentDefinitions(Map test) { + if (!(test.get("_segments") instanceof Map)) return Collections.emptyMap(); + @SuppressWarnings("unchecked") Map definitions = (Map) test.get("_segments"); + return definitions; + } + + private String validateSegmentDefinition(Map definitions, String segmentKey) { + Object rawDefinition = definitions.get(segmentKey); + if (!(rawDefinition instanceof Map)) { + return "segment \"" + segmentKey + "\" definition is missing"; + } + if (!((Map) rawDefinition).containsKey("conditions")) { + return "segment \"" + segmentKey + "\" definition has no conditions"; + } + return null; + } + + private TestError checkError(VariableChecks.CheckFailure failure, Integer childIndex) { + Map details = failure.details == null + ? new LinkedHashMap<>() : new LinkedHashMap<>(failure.details); + if (childIndex != null) details.put("childIndex", childIndex); + List path = new ArrayList<>(); + if (childIndex != null) path.addAll(Arrays.asList("children", childIndex)); + path.addAll(failure.path); + Object evaluatedValue = details.get("evaluated"); + if (evaluatedValue instanceof List) { + List> evaluated = new ArrayList<>(); + for (Object raw : (List) evaluatedValue) { + if (!(raw instanceof Map)) continue; + @SuppressWarnings("unchecked") Map leaf = new LinkedHashMap<>((Map) raw); + List leafPath = new ArrayList<>(); + if (childIndex != null) leafPath.addAll(Arrays.asList("children", childIndex)); + leafPath.add("checks"); + if (leaf.get("path") instanceof List) leafPath.addAll((List) leaf.get("path")); + leaf.put("path", leafPath); + evaluated.add(leaf); + } + details.put("evaluated", evaluated); + } + return new TestError(failure.message, "check", path, failure.expected, failure.actual, details); + } + + private TestError expectationError(String message, String type, List path, Object expected, + Object actual, Map details) { + return new TestError(message, type, path, expected, actual, details); + } + + private List appendPath(List prefix, Object... parts) { + List path = new ArrayList<>(prefix); + path.addAll(Arrays.asList(parts)); + return path; + } + + private Map childDetails(List prefix) { + Map details = new LinkedHashMap<>(); + if (prefix.size() >= 2 && "children".equals(prefix.get(prefix.size() - 2))) { + details.put("childIndex", prefix.get(prefix.size() - 1)); + } + return details; + } + + private String getterDiagnosticError(List diagnostics) { + for (com.featurevisor.sdk.FeaturevisorDiagnostic diagnostic : diagnostics) { + if (diagnostic.getLevel() == FeaturevisorLogLevel.ERROR) { + return diagnostic.getCode() + ": " + diagnostic.getMessage(); + } + } + return null; } private String formatTestValue(Object value) { @@ -627,26 +1114,75 @@ private String formatTestValue(Object value) { } @SuppressWarnings("unchecked") - private Featurevisor createInstanceForAssertion(DatafileContent datafile, Map assertion, FeaturevisorLogLevel level) { + private Featurevisor createInstanceForAssertion(DatafileContent datafile, Map assertion, + FeaturevisorLogLevel level, List diagnostics) { Featurevisor.FeaturevisorOptions options = new Featurevisor.FeaturevisorOptions() .datafile(datafile) .logLevel(level) .context((Map) assertion.getOrDefault("context", new HashMap<>())) - .stickyFeatures((Map) assertion.getOrDefault("stickyFeatures", assertion.get("sticky"))) - .stickyVariables((Map) assertion.get("stickyVariables")); + .stickyFeatures((Map) assertion.get("stickyFeatures")) + .stickyVariables((Map) assertion.get("stickyVariables")) + .onDiagnostic(diagnostics::add); + List modules = new ArrayList<>(); if (assertion.get("at") instanceof Number) { double at = ((Number) assertion.get("at")).doubleValue(); - options.modules(Collections.singletonList( - new FeaturevisorModule("test-module").bucketValue(bucket -> (int) (at * 1000)) - )); + modules.add(new FeaturevisorModule("test-module").bucketValue(bucket -> (int) (at * 1000))); } + if (Boolean.TRUE.equals(coverage)) { + modules.add(new FeaturevisorModule("test-coverage").afterEvaluation((evaluation, evaluateOptions) -> { + recordCoverage(evaluation); + return evaluation; + })); + } + if (!modules.isEmpty()) options.modules(modules); return Featurevisor.createFeaturevisor(options); } + private void recordCoverage(Evaluation evaluation) { + if (segmentHelperKeys.containsValue(evaluation.getFeatureKey())) return; + List> paths = evaluation.getVariableOverridePaths(); + if (paths != null && !paths.isEmpty()) { + for (List path : paths) addCoverageSelection(evaluation, new ArrayList<>(path)); + return; + } + if (evaluation.getVariableOverridePath() != null) { + addCoverageSelection(evaluation, new ArrayList<>(evaluation.getVariableOverridePath())); + return; + } + if (evaluation.getVariableOverrideKey() != null) { + addCoverageSelection(evaluation, Collections.singletonList(evaluation.getVariableOverrideKey())); + return; + } + if (evaluation.getRuleKey() != null || evaluation.getVariableOverrideIndex() != null) addCoverageSelection(evaluation, null); + } + + private void addCoverageSelection(Evaluation evaluation, List overridePath) { + if (evaluation.getRuleKey() == null && overridePath == null && evaluation.getVariableOverrideIndex() == null) return; + Map selection = linkedMap( + "set", activeSet, + "environment", activeEnvironment, + "target", activeTarget + ); + if (evaluation.getFeatureKey() != null) selection.put("featureKey", evaluation.getFeatureKey()); + if (evaluation.getVariableKey() != null) selection.put("variableKey", evaluation.getVariableKey()); + if (evaluation.getRuleKey() != null) selection.put("ruleKey", evaluation.getRuleKey()); + if (evaluation.getVariation() != null) selection.put("variation", evaluation.getVariation().getValue()); + if (overridePath != null) selection.put("overridePath", overridePath); + else if (evaluation.getVariableOverrideIndex() != null) selection.put("overrideIndex", evaluation.getVariableOverrideIndex()); + coverageSelections.add(selection); + } + private TestResult missingDatafileResult(Map assertion) { String environmentName = assertion.get("environment") instanceof String ? assertion.get("environment").toString() : "none"; String targetName = assertion.get("target") instanceof String ? " and target \"" + assertion.get("target") + "\"" : ""; - return new TestResult(true, " ✘ datafile not found for environment \"" + environmentName + "\"" + targetName + "\n", 0); + String message = "datafile not found for environment \"" + environmentName + "\"" + targetName; + Map details = linkedMap( + "environment", assertion.get("environment") instanceof String ? assertion.get("environment") : null, + "target", assertion.get("target") instanceof String ? assertion.get("target") : null + ); + return new TestResult(true, " ✘ " + message + "\n", 0, + Collections.singletonList(new TestError(message, "datafile", Collections.emptyList(), + "available datafile", null, details))); } /** @@ -679,6 +1215,18 @@ private Object getVariable(Object f, String featureKey, String variableKey, Map< return null; } + private Object getGlobalVariable(Object f, String variableKey, Map context) { + if (f instanceof Featurevisor) return ((Featurevisor) f).getVariable(variableKey, context); + if (f instanceof com.featurevisor.sdk.ChildInstance) return ((com.featurevisor.sdk.ChildInstance) f).getVariable(variableKey, context); + return null; + } + + private Evaluation evaluateGlobalVariable(Object f, String variableKey, Map context) { + if (f instanceof Featurevisor) return ((Featurevisor) f).evaluateVariable(variableKey, context); + if (f instanceof com.featurevisor.sdk.ChildInstance) return ((com.featurevisor.sdk.ChildInstance) f).evaluateVariable(variableKey, context); + return null; + } + private com.featurevisor.sdk.Evaluation evaluateFlag(Object f, String featureKey, Map context) { if (f instanceof Featurevisor) { return ((Featurevisor) f).evaluateFlag(featureKey, context); @@ -732,224 +1280,671 @@ private Object getEvaluationValue(com.featurevisor.sdk.Evaluation evaluation, St case "variableOverrideIndex": return evaluation.getVariableOverrideIndex(); case "variableOverrideKey": return evaluation.getVariableOverrideKey(); case "variableOverridePath": return evaluation.getVariableOverridePath(); + case "variableOverridePaths": return evaluation.getVariableOverridePaths(); case "bucketKey": return evaluation.getBucketKey(); case "bucketValue": return evaluation.getBucketValue(); case "ruleKey": return evaluation.getRuleKey(); case "forceIndex": return evaluation.getForceIndex(); case "force": return evaluation.getForce(); - case "sticky": return evaluation.getSticky(); + case "stickyFeature": return evaluation.getStickyFeature(); case "traffic": return evaluation.getTraffic(); - case "required": return evaluation.getRequired() != null ? evaluation.getRequired() : new ArrayList<>(); - case "requiredFeatures": return evaluation.getRequiredFeatures() != null ? evaluation.getRequiredFeatures() : new ArrayList<>(); + case "conditions": return evaluation.getConditions(); case "error": return evaluation.getError(); default: return null; } } - /** - * Test a segment - */ - private TestResult testSegment(Map assertion, Segment segment, FeaturevisorLogLevel level) { - @SuppressWarnings("unchecked") - Map context = (Map) assertion.getOrDefault("context", new HashMap<>()); - Object conditions = segment.getConditions(); - - boolean hasError = false; - StringBuilder errors = new StringBuilder(); - long startTime = System.nanoTime(); - - if (assertion.containsKey("expectedToMatch")) { - boolean actual = Conditions.allConditionsAreMatched(conditions, context); - boolean expected = (Boolean) assertion.get("expectedToMatch"); - if (actual != expected) { - hasError = true; - errors.append(" ✘ expectedToMatch: expected ").append(expected).append(" but received ").append(actual).append("\n"); + private int testReport() { + coverageSelections.clear(); + Map report = createReport(); + String originalSet = set; + try { + Map config = getConfig(rootDirectoryPath); + List reportSets = resolveReportSets(config); + if (caseId != null && originalSet == null && Boolean.TRUE.equals(config.get("sets"))) { + String caseSet = findCaseSet(reportSets); + if (caseSet == null) throw new IOException("No test case matches --case and the selected filters"); + reportSets = Collections.singletonList(caseSet); + } + for (String reportSet : reportSets) { + set = reportSet; + runReportSet(report, config, reportSet); + if (Boolean.TRUE.equals(uncovered)) appendUncovered(report, reportSet); + } + if (caseId != null && summaryCount(report, "assertions") == 0) { + throw new IOException("No test case matches --case and the selected filters"); } + if (Boolean.TRUE.equals(coverage)) report.put("coverage", coverageReport()); + } catch (Exception error) { + report.put("passed", false); + @SuppressWarnings("unchecked") List> errors = (List>) report.get("errors"); + errors.add(linkedMap("message", error.getMessage() == null ? error.toString() : error.getMessage())); + } finally { + set = originalSet; } - double duration = (System.nanoTime() - startTime) / 1_000_000.0; // Convert to milliseconds - return new TestResult(hasError, errors.toString(), duration); + try { + if (Boolean.TRUE.equals(json)) { + String output = Boolean.TRUE.equals(pretty) + ? objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(report) + : objectMapper.writeValueAsString(report); + System.out.println(output); + } else { + printHumanReport(report); + } + } catch (Exception error) { + if (!Boolean.TRUE.equals(json)) System.err.println("Error writing test report: " + error.getMessage()); + return 1; + } + return Boolean.TRUE.equals(report.get("passed")) ? 0 : 1; } - /** - * Run tests - */ - private void test() { - try { - String featurevisorProjectPath = rootDirectoryPath; + private Map createReport() { + Map summary = new LinkedHashMap<>(); + for (String key : Arrays.asList("specs", "assertions", "entitySpecs", "entityAssertions", "scenarioSpecs", "scenarioCases")) { + summary.put(key, linkedMap("passed", 0, "failed", 0)); + } + return linkedMap( + "schemaVersion", 1, + "runner", "java", + "passed", true, + "summary", summary, + "results", new ArrayList<>(), + "errors", new ArrayList<>() + ); + } - Map config = getConfig(featurevisorProjectPath); - List environments = getEnvironmentList(config); - List availableTargets = getTargets(featurevisorProjectPath); - List selectedTargets = targets.isEmpty() - ? availableTargets - : new ArrayList<>(new java.util.LinkedHashSet<>(targets)); + private List resolveReportSets(Map config) throws IOException { + if (set != null && Boolean.FALSE.equals(config.get("sets"))) { + throw new IOException("This project does not use Sets. Omit --set."); + } + if (set != null) return Collections.singletonList(set); + if (!Boolean.TRUE.equals(config.get("sets"))) return Collections.singletonList(null); + String configured = config.get("setsDirectoryPath") instanceof String + ? (String) config.get("setsDirectoryPath") : "sets"; + File directory = new File(configured); + if (!directory.isAbsolute()) directory = new File(rootDirectoryPath, configured); + File[] children = directory.listFiles(child -> java.nio.file.Files.isDirectory( + child.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)); + if (children == null || children.length == 0) throw new IOException("No project sets found in " + directory); + List names = new ArrayList<>(); + for (File child : children) names.add(child.getName()); + Collections.sort(names); + return names; + } - Map segmentsByKey = getSegments(featurevisorProjectPath); - Map datafileCache = new HashMap<>(); + private String findCaseSet(List reportSets) throws IOException { + for (String reportSet : reportSets) { + set = reportSet; + for (Map test : getTests(rootDirectoryPath, false)) { + String type = test.containsKey("scenario") ? "scenario" : test.containsKey("feature") + ? "feature" : test.containsKey("segment") ? "segment" : "variable"; + @SuppressWarnings("unchecked") List> assertions = test.get("assertions") instanceof List + ? (List>) test.get("assertions") : Collections.emptyList(); + for (Map assertion : assertions) { + @SuppressWarnings("unchecked") Map metadata = assertion.get("_case") instanceof Map + ? (Map) assertion.get("_case") : Collections.emptyMap(); + if (caseId.equals(metadata.get("id")) && assertionMatchesTargets(type, assertion)) return reportSet; + } + } + } + return null; + } - datafileCache.putAll(buildBaseDatafiles(featurevisorProjectPath, environments)); - datafileCache.putAll(buildTargetDatafiles(featurevisorProjectPath, environments, selectedTargets)); + private boolean assertionMatchesTargets(String type, Map assertion) { + return type.equals("segment") || targets.isEmpty() || assertion.get("target") == null + || targets.contains(String.valueOf(assertion.get("target"))); + } - System.out.println(); + private void runReportSet(Map report, Map config, String reportSet) throws IOException { + activeSet = reportSet; + List> tests = getTests(rootDirectoryPath); + validateNormalizedTests(tests); + if (caseId != null && !hasExecutableAssertion(tests)) { + throw new IOException("No test case matches --case and the selected filters"); + } + List environments = getEnvironmentList(config); + List availableTargets = getTargets(rootDirectoryPath); + List selectedTargets = targets.isEmpty() + ? availableTargets : new ArrayList<>(new LinkedHashSet<>(targets)); + Map segments = getSegments(rootDirectoryPath); + Map datafiles = new HashMap<>(); + datafiles.putAll(buildBaseDatafiles(rootDirectoryPath, environments)); + datafiles.putAll(buildTargetDatafiles(rootDirectoryPath, environments, selectedTargets)); + + FeaturevisorLogLevel level = getDiagnosticsLevel(); + for (Map test : tests) { + String type = test.containsKey("scenario") ? "scenario" : test.containsKey("feature") + ? "feature" : test.containsKey("segment") ? "segment" : "variable"; + if (entityType != null && !entityType.equals(type)) continue; + String key = String.valueOf(test.get(type)); + @SuppressWarnings("unchecked") Map metadata = test.get("_test") instanceof Map + ? (Map) test.get("_test") : Collections.emptyMap(); + Map spec = linkedMap( + "type", type, + "key", key, + "source", metadata.getOrDefault("source", ""), + "set", metadata.containsKey("set") ? metadata.get("set") : reportSet, + "passed", true, + "cases", new ArrayList<>() + ); + @SuppressWarnings("unchecked") List> assertions = test.get("assertions") instanceof List + ? (List>) test.get("assertions") : Collections.emptyList(); + int assertionIndex = 0; + for (Map assertion : assertions) { + if (!assertionMatchesTargets(type, assertion)) { + assertionIndex++; + continue; + } + activeEnvironment = assertion.get("environment") instanceof String ? (String) assertion.get("environment") : null; + activeTarget = assertion.get("target") instanceof String ? (String) assertion.get("target") : null; + TestResult result = executeReportCase(type, key, assertion, datafiles, segments, level, + segmentDefinitions(test)); + Map reportCase = reportCase(type, assertion, assertionIndex, reportSet, result); + @SuppressWarnings("unchecked") List> cases = (List>) spec.get("cases"); + cases.add(reportCase); + if (result.hasError) spec.put("passed", false); + assertionIndex++; + } + @SuppressWarnings("unchecked") List> cases = (List>) spec.get("cases"); + if (!cases.isEmpty()) appendReportSpec(report, spec); + } + } - FeaturevisorLogLevel level = getDiagnosticsLevel(); - List> tests = getTests(featurevisorProjectPath); + private void validateNormalizedTests(List> tests) throws IOException { + for (Map test : tests) { + int discriminators = 0; + for (String candidate : Arrays.asList("feature", "segment", "variable", "scenario")) { + if (test.containsKey(candidate)) discriminators++; + } + if (discriminators != 1) { + throw new IOException("Test specification requires exactly one of feature, segment, variable, or scenario"); + } + if (!(test.get("assertions") instanceof List) || ((List) test.get("assertions")).isEmpty()) { + throw new IOException("Test specification requires assertions"); + } + } + } - if (tests.isEmpty()) { - System.out.println("No tests found"); - return; + private boolean hasExecutableAssertion(List> tests) { + for (Map test : tests) { + String type = test.containsKey("scenario") ? "scenario" : test.containsKey("feature") + ? "feature" : test.containsKey("segment") ? "segment" : "variable"; + @SuppressWarnings("unchecked") List> assertions = test.get("assertions") instanceof List + ? (List>) test.get("assertions") : Collections.emptyList(); + for (Map assertion : assertions) { + if (assertionMatchesTargets(type, assertion)) return true; } + } + return false; + } - int passedTestsCount = 0; - int failedTestsCount = 0; - int passedAssertionsCount = 0; - int failedAssertionsCount = 0; + private TestResult executeReportCase(String type, String key, Map assertion, + Map datafiles, Map segments, FeaturevisorLogLevel level, + Map segmentDefinitions) throws IOException { + List diagnostics = new ArrayList<>(); + DatafileContent datafile = datafiles.get(selectDatafileKeyForAssertion(assertion, datafiles)); + Map definitions = new LinkedHashMap<>(segmentDefinitions); + if (type.equals("segment") && !definitions.containsKey(key) && segments.containsKey(key)) { + definitions.put(key, objectMapper.convertValue(segments.get(key), Map.class)); + } + if (datafile == null) { + if (!type.equals("segment") || assertion.containsKey("environment") || assertion.containsKey("target")) { + return missingDatafileResult(assertion); + } + datafile = new DatafileContent("4", "segment-tests"); + datafile.setFeatures(new LinkedHashMap<>()); datafile.setSegments(new LinkedHashMap<>()); + } + if (type.equals("segment")) { + String error = SegmentEvaluations.validate(key, datafile, definitions); + if (error != null) return new TestResult(true, error, 0, List.of(new TestError( + error, "segment", List.of("expectedToMatch"), "valid segment references", null, null))); + } + String ownerVariable = type.equals("variable") ? key : null; + List preflight = preflightExpandedAssertion( + assertion, ownerVariable, datafile, segmentDefinitions + ); + if (!preflight.isEmpty()) { + StringBuilder messages = new StringBuilder(); + List reportErrors = new ArrayList<>(); + for (VariableChecks.CheckFailure failure : preflight) { + messages.append(" ✘ ").append(failure.message).append("\n"); + Object childIndex = failure.details == null ? null : failure.details.get("childIndex"); + reportErrors.add(checkError(failure, childIndex instanceof Number + ? ((Number) childIndex).intValue() : null)); + } + return new TestResult(true, messages.toString(), 0, reportErrors); + } + if (Boolean.TRUE.equals(showDatafile) && !Boolean.TRUE.equals(json)) { + System.out.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(datafile)); + } + SegmentEvaluations prepared = new SegmentEvaluations(datafile, definitions); + segmentHelperKeys = prepared.featureKeys; + datafile = prepared.datafile; + Featurevisor sdk = createInstanceForAssertion(datafile, assertion, level, diagnostics); + try { + if (type.equals("segment")) { + Evaluation evaluation = sdk.evaluateFlag(segmentHelperKeys.get(key)); + boolean actual = Boolean.TRUE.equals(evaluation.getEnabled()); + boolean failed = evaluation.getError() != null || getterDiagnosticError(diagnostics) != null + || assertion.containsKey("expectedToMatch") && !Objects.equals(assertion.get("expectedToMatch"), actual); + String message = evaluation.getError() != null ? evaluation.getError().getMessage() + : getterDiagnosticError(diagnostics) != null ? getterDiagnosticError(diagnostics) + : "expectedToMatch: expected " + assertion.get("expectedToMatch") + " but received " + actual; + return new TestResult(failed, failed ? message : "", 0, failed ? List.of(new TestError( + message, "segment", List.of("expectedToMatch"), assertion.get("expectedToMatch"), actual, null)) : List.of()); + } + if (type.equals("feature")) return testFeature(assertion, key, sdk, level); + if (type.equals("variable")) { + return testGlobalVariable(assertion, key, sdk, datafile, diagnostics, segmentDefinitions); + } + return testScenario(assertion, sdk, datafile, diagnostics, segmentDefinitions); + } finally { + sdk.close(); + } + } - for (Map test : tests) { - String testKey = (String) test.get("key"); - @SuppressWarnings("unchecked") - List> assertions = (List>) test.get("assertions"); - - if ((test.containsKey("feature") || test.containsKey("variable")) && !targets.isEmpty()) { - assertions = assertions.stream().filter(assertion -> { - Object assertionTarget = assertion.get("target"); - return assertionTarget == null || targets.contains(assertionTarget.toString()); - }).collect(java.util.stream.Collectors.toList()); - if (assertions.isEmpty()) { - continue; + private List preflightExpandedAssertion( + Map assertion, String ownerVariable, DatafileContent datafile, + Map segmentDefinitions) { + List failures = new ArrayList<>(); + if (assertion.containsKey("checks")) { + failures.addAll(VariableChecks.preflight(assertion.get("checks"), ownerVariable, + datafile, segmentDefinitions, "checks")); + } + if (assertion.get("children") instanceof List) { + int index = 0; + for (Object raw : (List) assertion.get("children")) { + if (raw instanceof Map) { + @SuppressWarnings("unchecked") Map child = (Map) raw; + if (child.containsKey("checks")) { + for (VariableChecks.CheckFailure failure : VariableChecks.preflight( + child.get("checks"), ownerVariable, datafile, segmentDefinitions, + "children[" + index + "].checks")) { + Map details = failure.details == null + ? new LinkedHashMap<>() : new LinkedHashMap<>(failure.details); + details.put("childIndex", index); + failures.add(new VariableChecks.CheckFailure(failure.message, failure.path, + failure.expected, failure.actual, details)); + } } } + index++; + } + } + return failures; + } + private Map reportCase(String type, Map assertion, int index, + String reportSet, TestResult result) { + @SuppressWarnings("unchecked") Map metadata = assertion.get("_case") instanceof Map + ? (Map) assertion.get("_case") : Collections.emptyMap(); + String id = metadata.get("id") instanceof String ? (String) metadata.get("id") + : fallbackCaseId(reportSet, type, index); + int authoredAssertionIndex = metadata.get("assertionIndex") instanceof Number + ? ((Number) metadata.get("assertionIndex")).intValue() : index; + List args = new ArrayList<>(); + args.add("test"); + args.add("--case=" + id); + if (projectDirectoryPath != null) args.add("--projectDirectoryPath=" + projectDirectoryPath); + if (reportSet != null) args.add("--set=" + reportSet); + for (String target : new LinkedHashSet<>(targets)) args.add("--target=" + target); + @SuppressWarnings("unchecked") Map contextValue = assertion.get("context") instanceof Map + ? (Map) assertion.get("context") : new LinkedHashMap<>(); + Object matrixValues = metadata.get("matrixValues") instanceof Map + ? metadata.get("matrixValues") : new LinkedHashMap<>(); + return linkedMap( + "id", id, + "description", assertion.get("description"), + "environment", assertion.get("environment") instanceof String ? assertion.get("environment") : null, + "target", assertion.get("target") instanceof String ? assertion.get("target") : null, + "context", contextValue, + "matrixValues", matrixValues, + "passed", !result.hasError, + "errors", structuredErrors(result.reportErrors, type, authoredAssertionIndex), + "rerun", linkedMap( + "command", "mvn -q -pl featurevisor-sdk exec:java -Dexec.mainClass=com.featurevisor.cli.CLI", + "args", args, + "argumentEncoding", "maven-exec" + ) + ); + } + private String fallbackCaseId(String reportSet, String type, int index) { + try { + return objectMapper.writeValueAsString(Arrays.asList(reportSet, type, "unknown", index, null)); + } catch (Exception ignored) { + return type + ":" + index; + } + } - StringBuilder results = new StringBuilder(); - boolean testHasError = false; - double testDuration = 0; - - for (Map assertion : assertions) { - TestResult testResult; - - if (test.containsKey("feature")) { - String assertionEnvironment = assertion.get("environment") instanceof String - ? (String) assertion.get("environment") - : null; - String selectedDatafileKey = selectDatafileKeyForAssertion(assertion, datafileCache); - - DatafileContent selectedDatafile = datafileCache.get(selectedDatafileKey); - if (selectedDatafile == null) { - testResult = missingDatafileResult(assertion); - } else { - @SuppressWarnings("unchecked") - Map effectiveAssertion = objectMapper.convertValue( - assertion, - new TypeReference>() {} - ); - - if (Boolean.TRUE.equals(showDatafile)) { - System.out.println(); - System.out.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(selectedDatafile)); - System.out.println(); + private List> structuredErrors(List failures, String type, + int authoredAssertionIndex) { + List> errors = new ArrayList<>(); + for (TestError failure : failures) { + Map error = linkedMap("message", failure.message); + if (failure.type != null) error.put("type", failure.type); + List prefix = type.equals("scenario") + ? Collections.emptyList() : Arrays.asList("assertions", authoredAssertionIndex); + if (failure.path != null && !failure.path.isEmpty()) { + error.put("path", appendPath(prefix, failure.path.toArray())); + } + if (failure.hasExpected) error.put("expected", reportValue(failure.expected)); + if (failure.hasActual) error.put("actual", reportValue(failure.actual)); + if (failure.details != null && !failure.details.isEmpty()) { + Map details = new LinkedHashMap<>(failure.details); + if (!prefix.isEmpty() && details.get("evaluated") instanceof List) { + List> evaluated = new ArrayList<>(); + for (Object raw : (List) details.get("evaluated")) { + if (!(raw instanceof Map)) continue; + @SuppressWarnings("unchecked") Map leaf = new LinkedHashMap<>((Map) raw); + if (leaf.get("path") instanceof List) { + leaf.put("path", appendPath(prefix, ((List) leaf.get("path")).toArray())); } + evaluated.add(leaf); + } + details.put("evaluated", evaluated); + } + error.put("details", reportValue(details)); + } + errors.add(error); + } + return errors; + } - Featurevisor f = createInstanceForAssertion(selectedDatafile, effectiveAssertion, level); - try { - testResult = testFeature(effectiveAssertion, (String) test.get("feature"), f, level); - } finally { - f.close(); - } - } + private Object reportValue(Object value) { + if (value == null || value instanceof String || value instanceof Boolean) return value; + if (value instanceof Number) { + double number = ((Number) value).doubleValue(); + if (!Double.isFinite(number)) return linkedMap("$type", String.valueOf(value)); + return value; + } + if (value instanceof Throwable) { + Throwable error = (Throwable) value; + return linkedMap("name", error.getClass().getSimpleName(), "message", error.getMessage()); + } + if (value instanceof Map) { + Map normalized = new LinkedHashMap<>(); + for (Map.Entry entry : ((Map) value).entrySet()) { + normalized.put(String.valueOf(entry.getKey()), reportValue(entry.getValue())); + } + return normalized; + } + if (value instanceof Iterable) { + List normalized = new ArrayList<>(); + for (Object entry : (Iterable) value) normalized.add(reportValue(entry)); + return normalized; + } + return String.valueOf(value); + } + private void appendReportSpec(Map report, Map spec) { + @SuppressWarnings("unchecked") List> results = (List>) report.get("results"); + results.add(spec); + boolean passed = Boolean.TRUE.equals(spec.get("passed")); + if (!passed) report.put("passed", false); + incrementSummary(report, "specs", passed); + incrementSummary(report, spec.get("type").equals("scenario") ? "scenarioSpecs" : "entitySpecs", passed); + @SuppressWarnings("unchecked") List> cases = (List>) spec.get("cases"); + for (Map reportCase : cases) { + boolean casePassed = Boolean.TRUE.equals(reportCase.get("passed")); + incrementSummary(report, "assertions", casePassed); + incrementSummary(report, spec.get("type").equals("scenario") ? "scenarioCases" : "entityAssertions", casePassed); + } + } - } else if (test.containsKey("variable")) { - String selectedDatafileKey = selectDatafileKeyForAssertion(assertion, datafileCache); - DatafileContent selectedDatafile = datafileCache.get(selectedDatafileKey); - if (selectedDatafile == null) { - testResult = missingDatafileResult(assertion); - } else { - if (Boolean.TRUE.equals(showDatafile)) { - System.out.println(); - System.out.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(selectedDatafile)); - System.out.println(); - } - Featurevisor f = createInstanceForAssertion(selectedDatafile, assertion, level); - try { - testResult = testGlobalVariable(assertion, (String) test.get("variable"), f); - } finally { - f.close(); - } - } - } else if (test.containsKey("segment")) { - testResult = testSegment(assertion, segmentsByKey.get(test.get("segment")), level); - } else { - continue; - } + private void incrementSummary(Map report, String key, boolean passed) { + @SuppressWarnings("unchecked") Map summary = (Map) report.get("summary"); + @SuppressWarnings("unchecked") Map counts = (Map) summary.get(key); + String status = passed ? "passed" : "failed"; + counts.put(status, ((Number) counts.get(status)).intValue() + 1); + } - testDuration += testResult.duration; + private int summaryCount(Map report, String key) { + @SuppressWarnings("unchecked") Map summary = (Map) report.get("summary"); + @SuppressWarnings("unchecked") Map counts = (Map) summary.get(key); + return ((Number) counts.get("passed")).intValue() + ((Number) counts.get("failed")).intValue(); + } + + private void appendUncovered(Map report, String reportSet) throws IOException { + List features = uncoveredKeys("features"); + List variables = uncoveredKeys("variables"); + @SuppressWarnings("unchecked") List> entries = (List>) report.computeIfAbsent("uncovered", key -> new ArrayList<>()); + entries.add(linkedMap("set", reportSet, "features", features, "variables", variables)); + } + + private List uncoveredKeys(String entity) throws IOException { + String output = executeCommandInDirectory(rootDirectoryPath, + "npx featurevisor list --" + entity + " --without-tests --json", selectionArguments()); + JsonNode root = objectMapper.readTree(output); + List keys = new ArrayList<>(); + if (root.isArray()) { + for (JsonNode item : root) { + if (item.isTextual()) keys.add(item.asText()); + else if (item.has("key")) keys.add(item.get("key").asText()); + else if (item.has("name")) keys.add(item.get("name").asText()); + } + } + Collections.sort(keys); + return keys; + } + + private Map coverageReport() { + TreeMap> unique = new TreeMap<>(); + for (Map selection : coverageSelections) { + try { + unique.put(objectMapper.writeValueAsString(selection), selection); + } catch (Exception ignored) { + unique.put(selection.toString(), selection); + } + } + return linkedMap("mode", "observed-selections", "selections", new ArrayList<>(unique.values())); + } - if (testResult.hasError) { - results.append(" ✘ ").append(assertion.get("description")).append(" (").append(String.format(Locale.ROOT, "%.2f", testResult.duration)).append("ms)\n"); - results.append(testResult.errors); - testHasError = true; - failedAssertionsCount++; + private void printHumanReport(Map report) { + @SuppressWarnings("unchecked") List> results = (List>) report.get("results"); + for (Map spec : results) { + @SuppressWarnings("unchecked") List> cases = (List>) spec.get("cases"); + boolean failed = !Boolean.TRUE.equals(spec.get("passed")); + if (Boolean.TRUE.equals(onlyFailures) && !failed) continue; + String source = spec.get("source") instanceof String && !((String) spec.get("source")).isEmpty() + ? (String) spec.get("source") : String.valueOf(spec.get("key")); + System.out.println("\nTesting: " + source); + System.out.println(" " + spec.get("type") + " \"" + spec.get("key") + "\":"); + for (int caseIndex = 0; caseIndex < cases.size(); caseIndex++) { + Map reportCase = cases.get(caseIndex); + if (Boolean.TRUE.equals(onlyFailures) && Boolean.TRUE.equals(reportCase.get("passed"))) continue; + System.out.println(" " + (Boolean.TRUE.equals(reportCase.get("passed")) ? "✔ " : "✘ ") + humanCaseLabel(reportCase, caseIndex)); + @SuppressWarnings("unchecked") List> errors = (List>) reportCase.get("errors"); + for (Map error : errors) { + if ("check".equals(error.get("type")) && error.get("details") instanceof Map) { + @SuppressWarnings("unchecked") Map details = (Map) error.get("details"); + String noun = "check"; + StringBuilder label = new StringBuilder(); + if (details.get("childIndex") instanceof Number) { + label.append("Child ").append(((Number) details.get("childIndex")).intValue() + 1).append(", "); + } + label.append(noun); + if (details.get("checkKey") instanceof String) { + label.append(" \"").append(details.get("checkKey")).append("\""); + } else if (details.get("checkIndex") instanceof Number) { + label.append(" ").append(((Number) details.get("checkIndex")).intValue() + 1); + } + if (details.get("checkDescription") instanceof String) { + label.append(" (").append(details.get("checkDescription")).append(")"); + } + System.out.println(" ✘ " + label + ": " + humanCheckMessage(error.get("message"))); } else { - results.append(" ✔ ").append(assertion.get("description")).append(" (").append(String.format(Locale.ROOT, "%.2f", testResult.duration)).append("ms)\n"); - passedAssertionsCount++; + System.out.println(" ✘ " + humanExpectationError(error)); } + if (error.get("path") instanceof List) System.out.println(" at " + humanPath((List) error.get("path"))); } - - if (!onlyFailures || (onlyFailures && testHasError)) { - System.out.println("\nTesting: " + testKey + " (" + String.format(Locale.ROOT, "%.2f", testDuration) + "ms)"); - System.out.print(results); - } - - if (testHasError) { - failedTestsCount++; - } else { - passedTestsCount++; + if (!errors.isEmpty()) { + @SuppressWarnings("unchecked") Map rerun = (Map) reportCase.get("rerun"); + @SuppressWarnings("unchecked") List args = (List) rerun.get("args"); + System.out.println(" Rerun (POSIX shell): " + rerun.get("command") + " " + + joinShellArgs(Collections.singletonList(mavenExecArgument(args)))); } } + } + @SuppressWarnings("unchecked") Map summary = (Map) report.get("summary"); + System.out.println(); + for (Map.Entry entry : linkedStringMap( + "entitySpecs", "Entity specs", "entityAssertions", "Entity assertions", + "scenarioSpecs", "Scenario specs", "scenarioCases", "Scenario cases", "assertions", "Total cases").entrySet()) { + @SuppressWarnings("unchecked") Map counts = (Map) summary.get(entry.getKey()); + System.out.println(entry.getValue() + ": " + counts.get("passed") + " passed, " + counts.get("failed") + " failed"); + } + @SuppressWarnings("unchecked") List> rootErrors = (List>) report.get("errors"); + for (Map error : rootErrors) System.err.println("Error running tests: " + error.get("message")); + if (report.get("uncovered") instanceof List) { + for (Object raw : (List) report.get("uncovered")) { + if (!(raw instanceof Map)) continue; + Map entry = (Map) raw; + System.out.println("Uncovered" + (entry.get("set") == null ? "" : " (set " + entry.get("set") + ")") + + ": features " + entry.get("features") + ", variables " + entry.get("variables")); + } + } + if (report.get("coverage") instanceof Map) { + Object selections = ((Map) report.get("coverage")).get("selections"); + System.out.println("Observed rule and override selections (not branch coverage):"); + if (!(selections instanceof List) || ((List) selections).isEmpty()) System.out.println(" No selections observed"); + else for (Object raw : (List) selections) { + if (raw instanceof Map) System.out.println(" " + humanCoverageSelection((Map) raw)); + } + } + } - System.out.println(); - System.out.println("Test specs: " + passedTestsCount + " passed, " + failedTestsCount + " failed"); - System.out.println("Assertions: " + passedAssertionsCount + " passed, " + failedAssertionsCount + " failed"); - System.out.println(); + private String humanCaseLabel(Map reportCase, int index) { + String description = reportCase.get("description") instanceof String ? (String) reportCase.get("description") : ""; + String label = description.isEmpty() ? "assertion " + (index + 1) : description; + List details = new ArrayList<>(); + if (reportCase.get("matrixValues") instanceof Map && !((Map) reportCase.get("matrixValues")).isEmpty()) { + Map values = (Map) reportCase.get("matrixValues"); + List entries = new ArrayList<>(); + values.keySet().stream().map(String::valueOf).sorted().forEach(key -> { + try { entries.add(key + "=" + objectMapper.writeValueAsString(values.get(key))); } + catch (Exception ignored) { entries.add(key + "=" + values.get(key)); } + }); + details.add("matrix " + String.join(", ", entries)); + } + if (reportCase.get("target") instanceof String) details.add("target " + reportCase.get("target")); + return details.isEmpty() ? label : label + " (" + String.join("; ", details) + ")"; + } - if (failedTestsCount > 0) { - System.exit(1); - } + private String humanPath(List path) { + List parts = new ArrayList<>(); + for (Object part : path) parts.add(part instanceof Number ? String.valueOf(((Number) part).intValue() + 1) : String.valueOf(part)); + return String.join(".", parts); + } - } catch (Exception e) { - System.err.println("Error running tests: " + e.getMessage()); - e.printStackTrace(); - System.exit(1); + private String humanExpectationError(Map error) { + if (!(error.get("path") instanceof List) || !error.containsKey("expected")) return String.valueOf(error.get("message")); + List path = (List) error.get("path"); + int offset = 0; + if (path.size() > 1 && "assertions".equals(path.get(0))) offset = 2; + String prefix = ""; + if (path.size() > offset + 1 && "children".equals(path.get(offset)) && path.get(offset + 1) instanceof Number) { + prefix = "Child " + (((Number) path.get(offset + 1)).intValue() + 1) + ", "; + offset += 2; } + if (offset >= path.size()) return String.valueOf(error.get("message")); + String section = humanPath(path.subList(offset, path.size())); + return prefix + section + ": expected " + humanValue(error.get("expected")) + + ", received " + humanValue(error.get("actual")); + } + + private String humanValue(Object value) { + if (value instanceof String) return (String) value; + try { return objectMapper.writeValueAsString(value); } + catch (Exception ignored) { return String.valueOf(value); } + } + + private String humanCheckMessage(Object message) { + return String.valueOf(message) + .replaceFirst("^(?:children\\[[0-9]+\\]\\.)?checks\\[[0-9]+\\].*? (?:failed|error):\\s*", "") + .replaceFirst("^(?:children\\[[0-9]+\\]\\.)?checks\\[[0-9]+\\]:\\s*", ""); + } + + private String humanCoverageSelection(Map selection) { + String owner = selection.get("featureKey") == null ? "global" : "feature " + selection.get("featureKey"); + if (selection.get("variableKey") != null) owner += ", variable " + selection.get("variableKey"); + if (selection.get("variation") != null) owner += ", variation " + selection.get("variation"); + if (selection.get("ruleKey") != null) owner += ", rule " + selection.get("ruleKey"); + if (selection.get("overridePath") instanceof List) owner += ", override " + humanPath((List) selection.get("overridePath")); + else if (selection.get("overrideIndex") instanceof Number) owner += ", override " + (((Number) selection.get("overrideIndex")).intValue() + 1); + return owner; + } + + private String joinShellArgs(List args) { + List quoted = new ArrayList<>(); + for (String arg : args) quoted.add("'" + arg.replace("'", "'\\''") + "'"); + return String.join(" ", quoted); + } + + private String mavenExecArgument(List args) { + List quoted = new ArrayList<>(); + for (String arg : args) quoted.add("'" + arg.replace("'", "'\"'\"'") + "'"); + return "-Dexec.args=" + String.join(" ", quoted); + } + + private Map linkedStringMap(String... values) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < values.length; i += 2) map.put(values[i], values[i + 1]); + return map; + } + + private Map linkedMap(Object... values) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < values.length; i += 2) map.put((String) values[i], values[i + 1]); + return map; } /** * Run benchmark */ - private void benchmark() { + private int benchmark() { + return benchmark(false); + } + + private int runRuntimeSelections(java.util.function.IntSupplier evaluate) throws IOException { + String originalSet = set; + List originalTargets = targets; + List selectedSets = resolveReportSets(getConfig(rootDirectoryPath)); + List selectedTargets = targets.isEmpty() ? Collections.singletonList(null) + : new ArrayList<>(new java.util.LinkedHashSet<>(targets)); try { - if (environment == null) { - System.out.println("Environment is required"); - return; + for (String selectedSet : selectedSets) { + set = selectedSet; + for (String target : selectedTargets) { + targets = target == null ? Collections.emptyList() : Collections.singletonList(target); + if (evaluate.getAsInt() != 0) return 1; + } } + return 0; + } finally { + set = originalSet; + targets = originalTargets; + } + } - if (feature == null && variable == null) { - System.out.println("Feature or variable is required"); - return; + private int benchmark(boolean selectionResolved) { + try { + if (n == null || n < 1) { + System.err.println("Iterations must be a positive integer"); + return 1; } - if (targets.size() > 1) { - List requested = new ArrayList<>(new java.util.LinkedHashSet<>(targets)); - for (String target : requested) { - targets = Collections.singletonList(target); - benchmark(); - } - targets = requested; - return; + if (feature == null && variable == null) { + System.err.println("Feature or variable is required"); + return 1; } + if (variation && (feature == null || variable != null)) { + System.err.println("Variation requires a feature and cannot be combined with a variable"); + return 1; + } + + if (!selectionResolved) return runRuntimeSelections(() -> benchmark(true)); Map contextMap = new HashMap<>(); if (context != null) { @@ -968,7 +1963,8 @@ private void benchmark() { System.out.println("Benchmark Featurevisor feature"); if (feature != null) System.out.println(" Feature: " + feature); - System.out.println(" Environment: " + environment); + System.out.println(" Environment: " + (environment == null ? "none" : environment)); + if (set != null) System.out.println(" Set: " + set); if (target != null) System.out.println(" Target: " + target); System.out.println(" Iterations: " + n); @@ -1017,38 +2013,34 @@ private void benchmark() { System.out.println("Minimum duration: " + String.format(Locale.ROOT, "%.6f", minDurationNs / 1_000_000.0) + "ms"); System.out.println("Average duration: " + String.format(Locale.ROOT, "%.6f", duration / n) + "ms"); System.out.println("Maximum duration: " + String.format(Locale.ROOT, "%.6f", maxDurationNs / 1_000_000.0) + "ms"); - + f.close(); + return 0; } catch (Exception e) { System.err.println("Error running benchmark: " + e.getMessage()); - e.printStackTrace(); - System.exit(1); + return 1; } } /** * Assess distribution */ - private void assessDistribution() { + private int assessDistribution() { + return assessDistribution(false); + } + + private int assessDistribution(boolean selectionResolved) { try { - if (environment == null) { - System.out.println("Environment is required"); - return; + if (n == null || n < 1) { + System.err.println("Iterations must be a positive integer"); + return 1; } if (feature == null) { - System.out.println("Feature is required"); - return; + System.err.println("Feature is required"); + return 1; } - if (targets.size() > 1) { - List requested = new ArrayList<>(new java.util.LinkedHashSet<>(targets)); - for (String target : requested) { - targets = Collections.singletonList(target); - assessDistribution(); - } - targets = requested; - return; - } + if (!selectionResolved) return runRuntimeSelections(() -> assessDistribution(true)); Map contextMap = new HashMap<>(); if (context != null) { @@ -1066,7 +2058,8 @@ private void assessDistribution() { System.out.println("Assess Featurevisor distribution"); System.out.println(" Feature: " + feature); - System.out.println(" Environment: " + environment); + System.out.println(" Environment: " + (environment == null ? "none" : environment)); + if (set != null) System.out.println(" Set: " + set); if (target != null) System.out.println(" Target: " + target); System.out.println(" Iterations: " + n); @@ -1110,11 +2103,11 @@ private void assessDistribution() { double percentage = (count.doubleValue() / n) * 100; System.out.println(" - " + val + ": " + count + " (" + String.format(Locale.ROOT, "%.2f", percentage) + "%)"); } - + f.close(); + return 0; } catch (Exception e) { System.err.println("Error assessing distribution: " + e.getMessage()); - e.printStackTrace(); - System.exit(1); + return 1; } } @@ -1125,11 +2118,40 @@ private static class TestResult { public final boolean hasError; public final String errors; public final double duration; + public final List reportErrors; public TestResult(boolean hasError, String errors, double duration) { + this(hasError, errors, duration, new ArrayList<>()); + } + + public TestResult(boolean hasError, String errors, double duration, List reportErrors) { this.hasError = hasError; this.errors = errors; this.duration = duration; + this.reportErrors = reportErrors; + } + } + + private static class TestError { + final String message; + final String type; + final List path; + final Object expected; + final Object actual; + final Map details; + final boolean hasExpected; + final boolean hasActual; + + TestError(String message, String type, List path, Object expected, Object actual, + Map details) { + this.message = message; + this.type = type; + this.path = path; + this.expected = expected; + this.actual = actual; + this.details = details; + this.hasExpected = true; + this.hasActual = true; } } diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/cli/SegmentEvaluations.java b/featurevisor-sdk/src/main/java/com/featurevisor/cli/SegmentEvaluations.java new file mode 100644 index 0000000..81e7223 --- /dev/null +++ b/featurevisor-sdk/src/main/java/com/featurevisor/cli/SegmentEvaluations.java @@ -0,0 +1,125 @@ +package com.featurevisor.cli; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.featurevisor.sdk.DatafileContent; +import com.featurevisor.sdk.Feature; +import com.featurevisor.sdk.Segment; +import com.featurevisor.sdk.Traffic; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** SDK gates and structural preflight for compiled execution protocol segments. */ +final class SegmentEvaluations { + private static final ObjectMapper JSON = new ObjectMapper(); + final DatafileContent datafile; + final Map featureKeys = new LinkedHashMap<>(); + + SegmentEvaluations(DatafileContent source, Map definitions) { + datafile = new DatafileContent(source.getSchemaVersion(), source.getRevision()); + datafile.setFeaturevisorVersion(source.getFeaturevisorVersion()); + datafile.setVariables(source.getVariables()); + Map segments = new LinkedHashMap<>(); + if (source.getSegments() != null) segments.putAll(source.getSegments()); + definitions.forEach((key, value) -> { + if (!segments.containsKey(key)) segments.put(key, JSON.convertValue(value, Segment.class)); + }); + datafile.setSegments(segments); + Map features = new LinkedHashMap<>(); + if (source.getFeatures() != null) features.putAll(source.getFeatures()); + for (String key : definitions.keySet()) { + String helper = "__featurevisor_segment_test__" + key; + while (features.containsKey(helper)) helper += "_"; + Feature feature = new Feature(helper); + feature.setConditions(key); + feature.setBucketBy(new com.featurevisor.sdk.Bucket("__segment_test__")); + Traffic traffic = new Traffic("segment"); + traffic.setConditions("*"); traffic.setPercentage(100000); + feature.setTraffic(List.of(traffic)); + features.put(helper, feature); + featureKeys.put(key, helper); + } + datafile.setFeatures(features); + } + + static String validate(String segment, DatafileContent datafile, Map definitions) { + try { + List> pending = new ArrayList<>(); + pending.add(Map.of("segment", segment)); + Set seen = new HashSet<>(); + for (int cursor = 0; cursor < pending.size(); cursor++) { + Map reference = pending.get(cursor); + String id = JSON.writeValueAsString(reference); + if (!seen.add(id)) continue; + if (reference.containsKey("segment")) { + String key = (String) reference.get("segment"); + Object value = definitions.get(key); + if (value == null && datafile.getSegments() != null) value = datafile.getSegments().get(key); + if (value == null) return "segment \"" + key + "\" definition is missing"; + Map entry = JSON.convertValue(value, Map.class); + if (!entry.containsKey("conditions")) return "segment \"" + key + "\" definition has no conditions"; + visit(entry.get("conditions"), pending, false); + } else if (reference.containsKey("feature")) { + String key = (String) reference.get("feature"); + Feature feature = datafile.getFeature(key); + if (feature == null) return "feature \"" + key + "\" is absent from the selected datafile"; + Object variable = reference.get("variable"); + if (variable != null && (feature.getVariablesSchema() == null || !feature.getVariablesSchema().containsKey(variable))) { + return "feature variable \"" + key + "." + variable + "\" is absent from the selected datafile"; + } + selectors(JSON.convertValue(feature, Map.class), pending); + if (feature.getForce() != null) for (Object entry : feature.getForce()) selectors(JSON.convertValue(entry, Map.class), pending); + if (feature.getTraffic() != null) for (Traffic entry : feature.getTraffic()) { + selectors(JSON.convertValue(entry, Map.class), pending); + if (variable != null && entry.getVariableOverrides() != null) overrides(entry.getVariableOverrides().get(variable), pending); + } + if (variable != null && feature.getVariations() != null) feature.getVariations().forEach(entry -> { + if (entry.getVariableOverrides() != null) overrides(entry.getVariableOverrides().get(variable), pending); + }); + } else { + String key = (String) reference.get("variable"); + Object value = datafile.getVariables() == null ? null : datafile.getVariables().get(key); + if (value == null) return "global variable \"" + key + "\" is absent from the selected datafile"; + Map entry = JSON.convertValue(value, Map.class); + selectors(entry, pending); + overrides(entry.get("overrides"), pending); overrides(entry.get("overrideProgram"), pending); + Object compose = entry.get("compose"); + if (compose instanceof String) pending.add(Map.of("variable", compose)); + else visit(compose, pending, false); + } + } + return null; + } catch (Exception error) { return "invalid segment dependency: " + error.getMessage(); } + } + + private static void selectors(Map value, List> pending) { + visit(value.get("conditions"), pending, false); + visit(value.get("segments"), pending, false); + visit(value.get("requiredFeatures"), pending, true); + } + private static void overrides(Object values, List> pending) { + if (values instanceof List) for (Object value : (List) values) selectors(JSON.convertValue(value, Map.class), pending); + } + private static void visit(Object value, List> pending, boolean legacy) { + if (value instanceof String) { + String text = (String) value; + if (text.equals("*")) return; + if (text.stripLeading().matches("(?s)^[\\[{\"].*")) { + try { visit(JSON.readValue(text, Object.class), pending, legacy); } + catch (Exception error) { throw new IllegalArgumentException("invalid conditions", error); } + } else pending.add(Map.of(legacy ? "feature" : "segment", text)); + } else if (value instanceof List) for (Object child : (List) value) visit(child, pending, legacy); + else if (value instanceof Map) { + Map map = (Map) value; + if (map.get("variable") instanceof String || map.get("feature") instanceof String) { + Map reference = new LinkedHashMap<>(); + if (map.get("variable") instanceof String) reference.put("variable", map.get("variable")); + if (map.get("feature") instanceof String) reference.put("feature", map.get("feature")); + pending.add(reference); + } else if (!map.containsKey("attribute")) for (String group : List.of("and", "or", "not")) visit(map.get(group), pending, legacy); + } + } +} diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/cli/VariableChecks.java b/featurevisor-sdk/src/main/java/com/featurevisor/cli/VariableChecks.java new file mode 100644 index 0000000..b266bc8 --- /dev/null +++ b/featurevisor-sdk/src/main/java/com/featurevisor/cli/VariableChecks.java @@ -0,0 +1,826 @@ +package com.featurevisor.cli; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.featurevisor.sdk.DatafileContent; +import com.featurevisor.sdk.CompareVersions; +import com.featurevisor.sdk.Feature; +import java.time.DateTimeException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.*; +import java.util.function.BiPredicate; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +final class VariableChecks { + interface Resolver { Resolution resolve(Map reference); } + interface FeatureResolver { Resolution resolve(String feature, String getter); } + interface SegmentResolver { Resolution resolve(String segment); } + interface SegmentValidator { String validate(String segment); } + + static final class Resolution { + final Object value; + final String error; + Resolution(Object value, String error) { this.value = value; this.error = error; } + static Resolution value(Object value) { return new Resolution(value, null); } + static Resolution error(String error) { return new Resolution(null, error); } + } + + static final class CheckFailure { + final String message; + final List path; + final Object expected; + final Object actual; + final Map details; + + CheckFailure(String message, List path, Object expected, Object actual, Map details) { + this.message = message; + this.path = path; + this.expected = expected; + this.actual = actual; + this.details = details; + } + } + + private static final Set OPERATORS = new HashSet<>(Arrays.asList( + "equals", "notEquals", "exists", "notExists", "isNull", "notNull", "empty", "notEmpty", + "greaterThan", "greaterThanOrEquals", "lessThan", "lessThanOrEquals", + "contains", "notContains", "startsWith", "endsWith", "semverEquals", + "semverNotEquals", "semverGreaterThan", "semverGreaterThanOrEquals", + "semverLessThan", "semverLessThanOrEquals", "before", "after", "includes", + "notIncludes", "includesAny", "notIncludesAny", "matches", "notMatches", "in", "notIn" + )); + private static final Set UNSAFE = new HashSet<>(Arrays.asList( + "*", "__proto__", "prototype", "constructor" + )); + private static final ObjectMapper JSON = new ObjectMapper(); + private static final Pattern DATE = Pattern.compile( + "^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})(?:\\.(\\d{1,9}))?(Z|([+-])(\\d{2}):(\\d{2}))$" + ); + + private final Resolver resolver; + private final FeatureResolver featureResolver; + private final SegmentResolver segmentResolver; + private final SegmentValidator segmentValidator; + private String failedPath; + private String failedDetail; + + VariableChecks(Resolver resolver) { + this(resolver, + (feature, getter) -> Resolution.error("feature expectations are unavailable"), + segment -> Resolution.error("segment \"" + segment + "\" definition is missing"), + segment -> "segment \"" + segment + "\" definition is missing"); + } + + VariableChecks(Resolver resolver, FeatureResolver featureResolver) { + this(resolver, featureResolver, + segment -> Resolution.error("segment \"" + segment + "\" definition is missing"), + segment -> "segment \"" + segment + "\" definition is missing"); + } + + VariableChecks(Resolver resolver, FeatureResolver featureResolver, SegmentResolver segmentResolver, + SegmentValidator segmentValidator) { + this.resolver = resolver; + this.featureResolver = featureResolver; + this.segmentResolver = segmentResolver; + this.segmentValidator = segmentValidator; + } + + List evaluate(Object checks) { + List messages = new ArrayList<>(); + for (CheckFailure failure : evaluateStructured(checks)) messages.add(failure.message); + return messages; + } + + static List preflight(Object checks, String ownerVariable, + DatafileContent datafile, Map segmentDefinitions, String path) { + if (!(checks instanceof List) || ((List) checks).isEmpty()) { + return Collections.singletonList(new CheckFailure( + path + " error: checks must be a nonempty array", Arrays.asList("checks"), + "valid checks", checks, null)); + } + VariableChecks validator = new VariableChecks(reference -> Resolution.error("not evaluated")); + List list = (List) checks; + try { + validator.validateList(list, path); + } catch (CheckException error) { + return Collections.singletonList(new CheckFailure( + error.path + " error: " + error.getMessage(), validator.topLevelPath(error.path), + "valid checks", error.getMessage(), null)); + } + try { + for (int i = 0; i < list.size(); i++) { + validator.validateReferences(validator.map(list.get(i), path + "[" + i + "]", "check"), + ownerVariable, datafile, segmentDefinitions, path + "[" + i + "]"); + } + return Collections.emptyList(); + } catch (CheckException error) { + return Collections.singletonList(new CheckFailure( + error.path + " error: " + error.getMessage(), validator.topLevelPath(error.path), + "valid check references", error.getMessage(), null)); + } + } + + private void validateReferences(Map value, String ownerVariable, + DatafileContent datafile, Map segmentDefinitions, String path) { + for (String group : Arrays.asList("and", "or", "not")) { + if (value.get(group) instanceof List) { + List children = (List) value.get(group); + for (int i = 0; i < children.size(); i++) { + validateReferences(map(children.get(i), path + "." + group + "[" + i + "]", "check"), + ownerVariable, datafile, segmentDefinitions, path + "." + group + "[" + i + "]"); + } + return; + } + } + if (value.get("segment") instanceof String) { + String segment = (String) value.get("segment"); + Object raw = segmentDefinitions.get(segment); + if (!(raw instanceof Map)) fail(path, "segment \"" + segment + "\" definition is missing"); + if (!((Map) raw).containsKey("conditions")) { + fail(path, "segment \"" + segment + "\" definition has no conditions"); + } + String error = SegmentEvaluations.validate(segment, datafile, segmentDefinitions); + if (error != null) fail(path, error); + return; + } + if (value.containsKey("enabled") || value.containsKey("variation")) { + String feature = (String) value.get("feature"); + if (datafile.getFeatures() == null || !datafile.getFeatures().containsKey(feature)) { + fail(path, "feature \"" + feature + "\" is missing from the selected datafile"); + } + return; + } + validateReferenceMembership(value, ownerVariable, datafile, path); + if (value.get("compareTo") instanceof Map) { + @SuppressWarnings("unchecked") Map compareTo = (Map) value.get("compareTo"); + validateReferenceMembership(compareTo, ownerVariable, datafile, path + ".compareTo"); + } + } + + private void validateReferenceMembership(Map reference, String ownerVariable, + DatafileContent datafile, String path) { + String variable = reference.get("variable") instanceof String + ? (String) reference.get("variable") : ownerVariable; + String featureKey = reference.get("feature") instanceof String + ? (String) reference.get("feature") : null; + if (variable == null) fail(path, "variable is required"); + if (featureKey != null) { + Feature feature = datafile.getFeatures() == null ? null : datafile.getFeatures().get(featureKey); + if (feature == null) fail(path, "feature \"" + featureKey + "\" is missing from the selected datafile"); + if (variable == null || feature.getVariablesSchema() == null + || !feature.getVariablesSchema().containsKey(variable)) { + fail(path, "variable \"" + variable + "\" is missing from feature \"" + + featureKey + "\" in the selected datafile"); + } + } else if (variable != null && (datafile.getVariables() == null + || !datafile.getVariables().containsKey(variable))) { + fail(path, "global variable \"" + variable + "\" is missing from the selected datafile"); + } + } + + List evaluateStructured(Object checks) { + if (!(checks instanceof List) || ((List) checks).isEmpty()) { + return Collections.singletonList(new CheckFailure( + "checks error: checks must be a nonempty array", Arrays.asList("checks"), "valid checks", checks, null)); + } + List list = (List) checks; + try { + validateList(list, "checks"); + validateSegmentReferences(list, "checks"); + } catch (CheckException error) { + return Collections.singletonList(new CheckFailure( + error.path + " error: " + error.getMessage(), topLevelPath(error.path), "valid checks", error.getMessage(), null)); + } + List failures = new ArrayList<>(); + for (int i = 0; i < list.size(); i++) { + failedPath = null; + failedDetail = null; + List> evaluated = new ArrayList<>(); + Map check = map(list.get(i), "checks[" + i + "]", "check"); + try { + if (!node(list.get(i), "checks[" + i + "]", Arrays.asList(i), evaluated)) { + String path = failedPath == null ? "checks[" + i + "]" : failedPath; + Map details = new LinkedHashMap<>(); + details.put("checkIndex", i); + if (check.get("key") != null) details.put("checkKey", check.get("key")); + if (check.get("description") != null) details.put("checkDescription", check.get("description")); + details.put("evaluated", evaluated); + failures.add(new CheckFailure(path + " failed: " + + (failedDetail == null ? "check did not match" : failedDetail), + Arrays.asList("checks", i), true, false, details)); + } + } catch (CheckException error) { + Map details = new LinkedHashMap<>(); + details.put("checkIndex", i); + if (check.get("key") != null) details.put("checkKey", check.get("key")); + if (check.get("description") != null) details.put("checkDescription", check.get("description")); + details.put("evaluated", evaluated); + failures.add(new CheckFailure(error.path + " error: " + error.getMessage(), + Arrays.asList("checks", i), "successful comparison", error.getMessage(), details)); + } + } + return failures; + } + + private List topLevelPath(String path) { + int open = path.indexOf('['); + int close = path.indexOf(']', open + 1); + if (open < 0 || close < 0) return Arrays.asList("checks"); + try { + return Arrays.asList("checks", Integer.parseInt(path.substring(open + 1, close))); + } catch (NumberFormatException ignored) { + return Arrays.asList("checks"); + } + } + + private void validateList(List list, String path) { + Set keys = new HashSet<>(); + for (int i = 0; i < list.size(); i++) { + String itemPath = path + "[" + i + "]"; + Map value = map(list.get(i), itemPath, "check"); + validateMetadata(value, itemPath); + if (value.get("key") instanceof String && !keys.add((String) value.get("key"))) { + fail(itemPath, "duplicate sibling key \"" + value.get("key") + "\""); + } + validate(value, itemPath); + } + } + + private void validateMetadata(Map value, String path) { + if (value.containsKey("key") + && (!(value.get("key") instanceof String) || ((String) value.get("key")).isEmpty())) { + fail(path, "key must be a nonempty string"); + } + if (value.containsKey("description") && !(value.get("description") instanceof String)) { + fail(path, "description must be a string"); + } + } + + private void validate(Object raw, String path) { + Map value = map(raw, path, "check"); + validateMetadata(value, path); + List groups = groups(value); + if (!groups.isEmpty()) { + Set allowed = new HashSet<>(Arrays.asList(groups.get(0), "key", "description")); + if (groups.size() != 1 || !allowed.containsAll(value.keySet())) fail(path, "group must contain exactly one operator"); + String op = groups.get(0); + Object childrenValue = value.get(op); + if (!(childrenValue instanceof List) || ((List) childrenValue).isEmpty()) { + fail(path, op + " group must be a nonempty array"); + } + List children = (List) childrenValue; + validateList(children, path + "." + op); + return; + } + if (value.containsKey("segment") || value.containsKey("matches")) { + validateSegmentExpectation(value, path); + return; + } + if (value.containsKey("enabled") || value.containsKey("variation")) { + validateFeatureExpectation(value, path); + return; + } + Set allowed = new HashSet<>(Arrays.asList( + "variable", "feature", "path", "operator", "value", "compareTo", "regexFlags", "key", "description" + )); + for (String key : value.keySet()) if (!allowed.contains(key)) fail(path, "unknown property \"" + key + "\""); + Object operatorValue = value.get("operator"); + if (!(operatorValue instanceof String) || !OPERATORS.contains(operatorValue)) { + fail(path, "unknown or missing operator \"" + String.valueOf(operatorValue) + "\""); + } + String operator = (String) operatorValue; + boolean unary = Arrays.asList("exists", "notExists", "isNull", "notNull", "empty", "notEmpty").contains(operator); + boolean hasValue = value.containsKey("value"); + boolean hasReference = value.containsKey("compareTo"); + if (unary ? hasValue || hasReference : hasValue == hasReference) { + fail(path, unary ? operator + " accepts no operand" : "exactly one of value or compareTo is required"); + } + validateReference(value, path, false); + if (hasReference) validateReference(map(value.get("compareTo"), path, "compareTo"), path, true); + if (value.containsKey("regexFlags") && !operator.equals("matches") && !operator.equals("notMatches")) { + fail(path, "regexFlags is only valid for regex operators"); + } + if (value.containsKey("regexFlags") && value.get("regexFlags") == null) fail(path, "regexFlags must be a string"); + if (value.containsKey("regexFlags")) regexFlags(value.get("regexFlags"), path); + if (hasValue) validateLiteral(operator, value.get("value"), value.get("regexFlags"), path); + } + + private void validateSegmentExpectation(Map value, String path) { + Set allowed = new HashSet<>(Arrays.asList("segment", "matches", "key", "description")); + for (String key : value.keySet()) { + if (!allowed.contains(key)) fail(path, "segment check has unexpected property \"" + key + "\""); + } + if (!(value.get("segment") instanceof String) || ((String) value.get("segment")).trim().isEmpty()) { + fail(path, "segment must be a nonempty string"); + } + if (!(value.get("matches") instanceof Boolean)) { + fail(path, "segment \"" + value.get("segment") + "\" matches must be a boolean"); + } + } + + private void validateSegmentReferences(List checks, String path) { + for (int i = 0; i < checks.size(); i++) { + validateSegmentReference(checks.get(i), path + "[" + i + "]"); + } + } + + private void validateSegmentReference(Object raw, String path) { + Map value = map(raw, path, "check"); + for (String group : Arrays.asList("and", "or", "not")) { + if (value.get(group) instanceof List) { + List children = (List) value.get(group); + for (int i = 0; i < children.size(); i++) { + validateSegmentReference(children.get(i), path + "." + group + "[" + i + "]"); + } + return; + } + } + if (!(value.get("segment") instanceof String)) return; + String segment = (String) value.get("segment"); + String error = segmentValidator.validate(segment); + if (error != null) fail(path, error); + } + + private void validateFeatureExpectation(Map value, String path) { + Set allowed = new HashSet<>(Arrays.asList("feature", "enabled", "variation", "key", "description")); + for (String key : value.keySet()) { + if (!allowed.contains(key)) fail(path, "unknown property \"" + key + "\""); + } + if (!(value.get("feature") instanceof String) || ((String) value.get("feature")).isEmpty()) { + fail(path, "feature must be a nonempty string"); + } + if (value.containsKey("enabled") && !(value.get("enabled") instanceof Boolean)) { + fail(path, "enabled must be a boolean"); + } + if (value.containsKey("variation") + && value.get("variation") != null + && !(value.get("variation") instanceof String)) { + fail(path, "variation must be a string or null"); + } + } + + private void validateReference(Map reference, String path, boolean nested) { + Set allowed = nested + ? new HashSet<>(Arrays.asList("variable", "feature", "path")) + : new HashSet<>(Arrays.asList("variable", "feature", "path", "operator", "value", "compareTo", "regexFlags", "key", "description")); + for (String key : reference.keySet()) if (!allowed.contains(key)) fail(path, "unknown reference property \"" + key + "\""); + if (reference.containsKey("feature") && !reference.containsKey("variable")) { + fail(path, "feature references require an explicit variable"); + } + for (String key : Arrays.asList("variable", "feature")) { + if (reference.containsKey(key) && (!(reference.get(key) instanceof String) || ((String) reference.get(key)).isEmpty())) { + fail(path, key + " must be a nonempty string"); + } + } + if (reference.containsKey("path") && reference.get("path") == null) fail(path, "path cannot be null"); + parts(reference.get("path"), path); + } + + private void validateLiteral(String operator, Object value, Object flags, String path) { + if (operator.equals("equals") || operator.equals("notEquals")) { + if (!primitive(value)) fail(path, operator + " requires a scalar value"); + } else if (Arrays.asList("greaterThan", "greaterThanOrEquals", "lessThan", "lessThanOrEquals").contains(operator)) { + if (!(value instanceof Number) || !Double.isFinite(((Number) value).doubleValue())) fail(path, operator + " requires a finite numeric value"); + } else if (Arrays.asList("contains", "notContains", "startsWith", "endsWith").contains(operator)) { + if (!(value instanceof String)) fail(path, operator + " requires a string value"); + } else if (operator.equals("in") || operator.equals("notIn") || operator.equals("includesAny") || operator.equals("notIncludesAny")) { + if (!(value instanceof List)) fail(path, operator + " requires an array value"); + for (Object item : (List) value) if (!primitive(item)) fail(path, operator + " requires an array of scalar values"); + } else if (operator.equals("includes") || operator.equals("notIncludes")) { + if (!primitive(value)) fail(path, operator + " requires a scalar value"); + } else if (operator.equals("matches") || operator.equals("notMatches")) { + if (!(value instanceof String)) fail(path, operator + " requires a string value"); + validateRegex((String) value, flags, path); + } else if (operator.startsWith("semver")) { + if (!(value instanceof String)) fail(path, operator + " requires a string value"); + semver(value, value, path); + } else if (operator.equals("before") || operator.equals("after")) { + if (!(value instanceof String)) fail(path, operator + " requires a portable timestamp"); + parseDate((String) value, path); + } + } + + private boolean node(Object raw, String path, List tracePath, List> evaluated) { + Map value = map(raw, path, "check"); + String displayPath = namedPath(path, value); + List groups = groups(value); + if (groups.isEmpty()) return leaf(value, displayPath, tracePath, evaluated); + String op = groups.get(0); + List children = (List) value.get(op); + if (op.equals("and")) { + for (int i = 0; i < children.size(); i++) if (!node(children.get(i), path + ".and[" + i + "]", appendPath(tracePath, "and", i), evaluated)) return false; + return true; + } + if (op.equals("or")) { + String lastPath = null; + String lastDetail = null; + for (int i = 0; i < children.size(); i++) { + if (node(children.get(i), path + ".or[" + i + "]", appendPath(tracePath, "or", i), evaluated)) return true; + lastPath = failedPath; + lastDetail = failedDetail; + } + failedPath = lastPath == null ? path : lastPath; + failedDetail = lastDetail == null ? "no OR branch matched" : lastDetail; + return false; + } + for (int i = 0; i < children.size(); i++) if (!node(children.get(i), path + ".not[" + i + "]", appendPath(tracePath, "not", i), evaluated)) return true; + failedPath = path; + failedDetail = "NOT group matched"; + return false; + } + + private List appendPath(List path, Object... values) { + List result = new ArrayList<>(path); + result.addAll(Arrays.asList(values)); + return result; + } + + private boolean leaf(Map leaf, String path, List tracePath, List> evaluated) { + if (leaf.containsKey("segment") || leaf.containsKey("matches")) { + return segmentLeaf(leaf, path, tracePath, evaluated); + } + if (leaf.containsKey("enabled") || leaf.containsKey("variation")) { + return featureLeaf(leaf, path, tracePath, evaluated); + } + String operator = (String) leaf.get("operator"); + Map leftReference = referenceFrom(leaf); + Resolved left = resolve(leftReference, path, "left"); + Map rightReference = leaf.containsKey("compareTo") + ? map(leaf.get("compareTo"), path, "compareTo") : null; + Resolved right = rightReference == null + ? new Resolved(true, leaf.get("value"), display(leaf.get("value"))) + : resolve(rightReference, path, "right"); + Map trace = new LinkedHashMap<>(); + trace.put("path", tracePath); + if (leaf.get("key") != null) trace.put("key", leaf.get("key")); + if (leaf.get("description") != null) trace.put("description", leaf.get("description")); + trace.put("reference", leftReference); + trace.put("operator", operator); + trace.put("expected", reportValue(right)); + trace.put("actual", reportValue(left)); + if (!left.present) trace.put("actualMissing", true); + if (!right.present) trace.put("expectedMissing", true); + if (rightReference != null) trace.put("compareTo", rightReference); + evaluated.add(trace); + boolean matched = compare(operator, left, right, leaf.get("regexFlags"), path); + if (!matched) { + failedPath = path; + failedDetail = "left " + display(leftReference) + " resolved to " + left.display + "; " + + (rightReference == null ? "value" : "right " + display(rightReference)) + + " resolved to " + right.display + "; operator " + operator; + } + return matched; + } + + private boolean segmentLeaf(Map leaf, String path, List tracePath, + List> evaluated) { + String segment = (String) leaf.get("segment"); + boolean expected = (Boolean) leaf.get("matches"); + Resolution resolution = segmentResolver.resolve(segment); + if (resolution.error != null) fail(path, resolution.error); + if (!(resolution.value instanceof Boolean)) fail(path, "segment \"" + segment + "\" did not return a boolean match"); + boolean actual = (Boolean) resolution.value; + Map trace = new LinkedHashMap<>(); + trace.put("path", tracePath); + if (leaf.get("key") != null) trace.put("key", leaf.get("key")); + if (leaf.get("description") != null) trace.put("description", leaf.get("description")); + trace.put("reference", Collections.singletonMap("segment", segment)); + trace.put("property", "matches"); + trace.put("operator", "equals"); + trace.put("expected", expected); + trace.put("actual", actual); + evaluated.add(trace); + if (actual != expected) { + failedPath = path; + failedDetail = "segment \"" + segment + "\" expected matches=" + expected + ", actual matches=" + actual; + } + return actual == expected; + } + + private String namedPath(String path, Map value) { + List metadata = new ArrayList<>(); + if (value.get("key") instanceof String) metadata.add("key=" + display(value.get("key"))); + if (value.get("description") instanceof String) metadata.add("description=" + display(value.get("description"))); + return metadata.isEmpty() ? path : path + " [" + String.join(", ", metadata) + "]"; + } + + private boolean featureLeaf(Map leaf, String path, List tracePath, + List> evaluated) { + String feature = (String) leaf.get("feature"); + boolean matched = true; + List details = new ArrayList<>(); + if (leaf.containsKey("enabled")) { + boolean expected = (Boolean) leaf.get("enabled"); + Resolution actual = featureResolver.resolve(feature, "enabled"); + if (actual.error != null) { + fail(path, "feature \"" + feature + "\" enabled getter failed: " + actual.error); + } + Map trace = featureTrace(leaf, tracePath, "enabled", expected, actual.value); + evaluated.add(trace); + matched = Objects.equals(expected, actual.value); + details.add("expected enabled " + expected + ", actual " + actual.value); + } + if (leaf.containsKey("variation")) { + String expected = (String) leaf.get("variation"); + Resolution actual = featureResolver.resolve(feature, "variation"); + if (actual.error != null) { + fail(path, "feature \"" + feature + "\" variation getter failed: " + actual.error); + } + Map trace = featureTrace(leaf, tracePath, "variation", expected, actual.value); + evaluated.add(trace); + boolean variationMatched = Objects.equals(expected, actual.value); + matched = matched && variationMatched; + details.add("expected variation " + display(expected) + ", actual " + display(actual.value)); + } + if (!matched) { + failedPath = path; + failedDetail = "feature \"" + feature + "\": " + String.join("; ", details); + } + return matched; + } + + private Map featureTrace(Map leaf, List tracePath, + String property, Object expected, Object actual) { + Map trace = new LinkedHashMap<>(); + trace.put("path", tracePath); + if (leaf.get("key") != null) trace.put("key", leaf.get("key")); + if (leaf.get("description") != null) trace.put("description", leaf.get("description")); + trace.put("reference", Collections.singletonMap("feature", leaf.get("feature"))); + trace.put("property", property); + trace.put("operator", "equals"); + trace.put("expected", expected); + trace.put("actual", actual); + return trace; + } + + private Object reportValue(Resolved value) { + if (value.present) return value.value; + Map missing = new LinkedHashMap<>(); + missing.put("$type", "undefined"); + return missing; + } + + private Resolved resolve(Map reference, String path, String side) { + Resolution resolution = resolver.resolve(reference); + if (resolution.error != null) fail(path, side + " " + display(reference) + ": " + resolution.error); + Object current = resolution.value; + boolean present = true; + for (Object part : parts(reference.get("path"), path)) { + if (part instanceof Number) { + long index = ((Number) part).longValue(); + if (!(current instanceof List) || index >= ((List) current).size()) { + present = false; current = null; break; + } + current = ((List) current).get((int) index); + } else if (current instanceof List && "length".equals(part)) { + current = ((List) current).size(); + } else if (current instanceof List && ((String) part).matches("^(0|[1-9][0-9]*)$")) { + try { + long index = Long.parseLong((String) part); + if (index >= ((List) current).size()) { present = false; current = null; break; } + current = ((List) current).get((int) index); + } catch (NumberFormatException ignored) { present = false; current = null; break; } + } else if (current instanceof Map && ((Map) current).containsKey(part)) { + current = ((Map) current).get(part); + } else { + present = false; current = null; break; + } + } + return new Resolved(present, current, present ? display(current) : ""); + } + + private List parts(Object raw, String path) { + List source; + boolean stringPath = raw instanceof String; + if (raw == null) source = Collections.emptyList(); + else if (raw instanceof String) source = ((String) raw).isEmpty() + ? Collections.emptyList() : Arrays.asList(((String) raw).split("\\.", -1)); + else if (raw instanceof List) source = (List) raw; + else { fail(path, "path must be a string or array"); return Collections.emptyList(); } + List result = new ArrayList<>(); + for (Object value : source) { + if (value instanceof Number && !stringPath) { + double number = ((Number) value).doubleValue(); + if (number >= 0 && number == Math.rint(number) && number <= 9_007_199_254_740_991L) { + result.add(((Number) value).longValue()); continue; + } + } + if (value instanceof String) { + String text = (String) value; + if (stringPath && text.matches("^(0|[1-9][0-9]*)$")) { + try { + long index = Long.parseLong(text); + if (index > 9_007_199_254_740_991L) fail(path, "path index must be a safe integer"); + result.add(index); continue; + } catch (NumberFormatException ignored) { fail(path, "path index must be a safe integer"); } + } + if (!text.isEmpty() && !text.contains("*") && !UNSAFE.contains(text)) { result.add(text); continue; } + } + fail(path, "path contains an unsafe or invalid component"); + } + return result; + } + + private boolean compare(String operator, Resolved left, Resolved right, Object flags, String path) { + if (operator.equals("exists")) return left.present; + if (operator.equals("notExists")) return !left.present; + if (operator.equals("isNull")) return left.present && left.value == null; + if (operator.equals("notNull")) return left.present && left.value != null; + if (operator.equals("empty") || operator.equals("notEmpty")) { + if (!left.present || left.value == null) return false; + boolean empty; + if (left.value instanceof String) empty = ((String) left.value).isEmpty(); + else if (left.value instanceof List) empty = ((List) left.value).isEmpty(); + else if (left.value instanceof Map) empty = ((Map) left.value).isEmpty(); + else { + fail(path, operator + " requires a string, array, or object operand"); + return false; + } + return operator.equals("empty") ? empty : !empty; + } + if (!left.present || !right.present) return false; + switch (operator) { + case "equals": return equality(left.value, right.value, path); + case "notEquals": return !equality(left.value, right.value, path); + case "in": return membership(left.value, right.value, path); + case "notIn": return !membership(left.value, right.value, path); + case "contains": return strings(left.value, right.value, path, String::contains); + case "notContains": return !strings(left.value, right.value, path, String::contains); + case "startsWith": return strings(left.value, right.value, path, String::startsWith); + case "endsWith": return strings(left.value, right.value, path, String::endsWith); + case "greaterThan": return numbers(left.value, right.value, path, (a, b) -> a > b); + case "greaterThanOrEquals": return numbers(left.value, right.value, path, (a, b) -> a >= b); + case "lessThan": return numbers(left.value, right.value, path, (a, b) -> a < b); + case "lessThanOrEquals": return numbers(left.value, right.value, path, (a, b) -> a <= b); + case "includes": return includes(left.value, right.value, path); + case "notIncludes": return !includes(left.value, right.value, path); + case "includesAny": return includesAny(left.value, right.value, path); + case "notIncludesAny": return !includesAny(left.value, right.value, path); + case "matches": return matches(left.value, right.value, flags, path); + case "notMatches": return !matches(left.value, right.value, flags, path); + case "semverEquals": return semver(left.value, right.value, path) == 0; + case "semverNotEquals": return semver(left.value, right.value, path) != 0; + case "semverGreaterThan": return semver(left.value, right.value, path) > 0; + case "semverGreaterThanOrEquals": return semver(left.value, right.value, path) >= 0; + case "semverLessThan": return semver(left.value, right.value, path) < 0; + case "semverLessThanOrEquals": return semver(left.value, right.value, path) <= 0; + case "before": return date(left.value, right.value, path) < 0; + case "after": return date(left.value, right.value, path) > 0; + default: fail(path, "unknown operator \"" + operator + "\""); return false; + } + } + + private boolean equality(Object left, Object right, String path) { + if (!primitive(left) || !primitive(right)) fail(path, "equals/notEquals requires scalar operands"); + return scalarEquals(left, right); + } + private boolean scalarEquals(Object left, Object right) { + if (left instanceof Number && right instanceof Number) { + double a = ((Number) left).doubleValue(), b = ((Number) right).doubleValue(); + return Double.isFinite(a) && Double.isFinite(b) && a == b; + } + return left == null ? right == null : left.getClass().equals(right == null ? null : right.getClass()) && left.equals(right); + } + private boolean membership(Object left, Object right, String path) { + if (!(left == null || left instanceof String || left instanceof Number) || !(right instanceof List)) { + fail(path, "in/notIn requires a string, number, or null left value and an array"); + } + for (Object value : (List) right) if (scalarEquals(left, value)) return true; + return false; + } + private boolean includes(Object left, Object right, String path) { + if (!(left instanceof List) || !primitive(right)) fail(path, "includes/notIncludes requires an array and a scalar"); + for (Object value : (List) left) if (scalarEquals(value, right)) return true; + return false; + } + private boolean includesAny(Object left, Object right, String path) { + if (!(left instanceof List) || !(right instanceof List)) fail(path, "includesAny/notIncludesAny requires two arrays"); + for (Object candidate : (List) right) { + if (!primitive(candidate)) fail(path, "includesAny/notIncludesAny requires an array of primitive values"); + } + for (Object candidate : (List) right) { + for (Object item : (List) left) if (scalarEquals(item, candidate)) return true; + } + return false; + } + private boolean strings(Object left, Object right, String path, BiPredicate test) { + if (!(left instanceof String) || !(right instanceof String)) fail(path, "operator requires string operands"); + return test.test((String) left, (String) right); + } + private boolean numbers(Object left, Object right, String path, BiPredicate test) { + if (!(left instanceof Number) || !(right instanceof Number)) fail(path, "operator requires finite numeric operands"); + double a = ((Number) left).doubleValue(), b = ((Number) right).doubleValue(); + if (!Double.isFinite(a) || !Double.isFinite(b)) fail(path, "operator requires finite numeric operands"); + return test.test(a, b); + } + private boolean matches(Object left, Object right, Object rawFlags, String path) { + if (!(left instanceof String) || !(right instanceof String)) fail(path, "matches/notMatches requires string operands"); + String expression = (String) right; + String flags = regexFlags(rawFlags, path); + int options = 0; + if (flags.contains("i")) options |= Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE; + if (flags.contains("m")) options |= Pattern.MULTILINE; + if (flags.contains("s")) options |= Pattern.DOTALL; + validateRegex(expression, rawFlags, path); + try { return Pattern.compile(expression, options).matcher((String) left).find(); } + catch (PatternSyntaxException error) { fail(path, "invalid regular expression: " + error.getDescription()); return false; } + } + private void validateRegex(String expression, Object rawFlags, String path) { + String flags = regexFlags(rawFlags, path); + if (expression.contains("(?")) fail(path, "unsupported portable regular expression"); + if (Pattern.compile("\\\\(?:[1-9]|k<|k'|g<|g')").matcher(expression).find()) fail(path, "unsupported portable regular expression"); + if (Pattern.compile("(?:[?*+]|\\{\\d+(?:,\\d*)?\\})\\+").matcher(expression).find()) fail(path, "unsupported portable regular expression"); + int options = 0; + if (flags.contains("i")) options |= Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE; + if (flags.contains("m")) options |= Pattern.MULTILINE; + if (flags.contains("s")) options |= Pattern.DOTALL; + try { Pattern.compile(expression, options); } + catch (PatternSyntaxException error) { fail(path, "invalid regular expression: " + error.getDescription()); } + } + private String regexFlags(Object raw, String path) { + if (raw != null && !(raw instanceof String)) fail(path, "regexFlags must be a string"); + String flags = raw == null ? "" : (String) raw; + Set seen = new HashSet<>(); + for (char value : flags.toCharArray()) if ("gims".indexOf(value) < 0 || !seen.add(value)) fail(path, "invalid portable regex flags \"" + flags + "\""); + return flags; + } + private int semver(Object left, Object right, String path) { + if (!(left instanceof String) || !(right instanceof String)) fail(path, "semver operator requires string operands"); + try { + return CompareVersions.compareVersions((String) left, (String) right); + } catch (RuntimeException error) { + throw new CheckException(path, error.getMessage()); + } + } + private int date(Object left, Object right, String path) { + if (!(left instanceof String) || !(right instanceof String)) fail(path, "date operator requires portable timestamps"); + return parseDate((String) left, path).compareTo(parseDate((String) right, path)); + } + private Instant parseDate(String value, String path) { + java.util.regex.Matcher match = DATE.matcher(value); + if (!match.matches()) fail(path, "date operator requires a portable timestamp"); + int year = Integer.parseInt(match.group(1)); + if (year == 0) fail(path, "date operator requires a valid Gregorian timestamp"); + try { + int nanos = match.group(7) == null ? 0 + : Integer.parseInt((match.group(7) + "00").substring(0, 3)) * 1_000_000; + LocalDateTime local = LocalDateTime.of( + year, + Integer.parseInt(match.group(2)), + Integer.parseInt(match.group(3)), + Integer.parseInt(match.group(4)), + Integer.parseInt(match.group(5)), + Integer.parseInt(match.group(6)), + nanos + ); + int offsetSeconds = 0; + if (!"Z".equals(match.group(8))) { + int offsetHour = Integer.parseInt(match.group(10)); + int offsetMinute = Integer.parseInt(match.group(11)); + if (offsetHour > 14 || offsetMinute > 59 || (offsetHour == 14 && offsetMinute != 0)) { + fail(path, "date operator requires an offset up to 14:00"); + } + offsetSeconds = (offsetHour * 60 + offsetMinute) * 60; + if ("-".equals(match.group(9))) offsetSeconds = -offsetSeconds; + } + Instant instant = local.toInstant(ZoneOffset.ofTotalSeconds(offsetSeconds)); + int utcYear = instant.atOffset(ZoneOffset.UTC).getYear(); + if (utcYear < 1 || utcYear > 9999) fail(path, "date UTC instant is outside years 0001 to 9999"); + return instant; + } catch (DateTimeException | NumberFormatException error) { + fail(path, "date operator requires a valid Gregorian timestamp"); + return Instant.EPOCH; + } + } + + private boolean primitive(Object value) { return value == null || value instanceof String || value instanceof Number || value instanceof Boolean; } + private List groups(Map value) { + List result = new ArrayList<>(); + for (String op : Arrays.asList("and", "or", "not")) if (value.containsKey(op)) result.add(op); + return result; + } + @SuppressWarnings("unchecked") private Map map(Object value, String path, String label) { + if (!(value instanceof Map)) { fail(path, label + " must be an object"); return Collections.emptyMap(); } + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : ((Map) value).entrySet()) result.put(String.valueOf(entry.getKey()), entry.getValue()); + return result; + } + private Map referenceFrom(Map leaf) { + Map result = new LinkedHashMap<>(); + for (String key : Arrays.asList("variable", "feature", "path")) if (leaf.containsKey(key)) result.put(key, leaf.get(key)); + return result; + } + private String display(Object value) { try { return JSON.writeValueAsString(value); } catch (Exception ignored) { return String.valueOf(value); } } + private void fail(String path, String message) { throw new CheckException(path, message); } + + private static final class Resolved { + final boolean present; final Object value; final String display; + Resolved(boolean present, Object value, String display) { this.present = present; this.value = value; this.display = display; } + } + private static final class CheckException extends RuntimeException { + final String path; + CheckException(String path, String message) { super(message); this.path = path; } + } +} diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/ChildInstance.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/ChildInstance.java index 7a8e437..7e27a35 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/ChildInstance.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/ChildInstance.java @@ -17,6 +17,7 @@ public class ChildInstance { private Map context; private Map sticky; private Map stickyVariables; + private final java.util.concurrent.atomic.AtomicLong stateGeneration = new java.util.concurrent.atomic.AtomicLong(); private Emitter emitter; private final List parentUnsubscribers = new ArrayList<>(); @@ -26,8 +27,8 @@ public class ChildInstance { ChildInstance(Featurevisor parent, Map context, Map sticky, Map stickyVariables) { this.parent = parent; this.context = context != null ? new HashMap<>(context) : new HashMap<>(); - this.sticky = sticky; - this.stickyVariables = stickyVariables; + this.sticky = sticky != null ? new HashMap<>(sticky) : new HashMap<>(); + this.stickyVariables = stickyVariables != null ? new HashMap<>(stickyVariables) : new HashMap<>(); this.emitter = new Emitter(); } @@ -75,6 +76,7 @@ public void setContext(Map context, boolean replace) { this.context = new HashMap<>(this.context); this.context.putAll(context); } + stateGeneration.incrementAndGet(); FeaturevisorEventDetails eventDetails = new FeaturevisorEventDetails(); eventDetails.put("context", this.context); @@ -111,6 +113,7 @@ public void setStickyFeatures(Map sticky, boolean replace) { this.sticky = new HashMap<>(this.sticky != null ? this.sticky : new HashMap<>()); this.sticky.putAll(sticky); } + stateGeneration.incrementAndGet(); FeaturevisorEventDetails params = Events.getParamsForStickyFeaturesSetEvent( previousStickyFeatures, this.sticky, replace); @@ -123,6 +126,7 @@ public void setStickyVariables(Map sticky, boolean replace) { Map previous = this.stickyVariables != null ? new HashMap<>(this.stickyVariables) : new HashMap<>(); this.stickyVariables = replace ? new HashMap<>(sticky) : new HashMap<>(previous); if (!replace) this.stickyVariables.putAll(sticky); + stateGeneration.incrementAndGet(); this.emitter.trigger(FeaturevisorEventName.STICKY_VARIABLES_SET, Events.getParamsForStickyVariablesSetEvent(previous, this.stickyVariables, replace)); } public void setStickyVariables(Map sticky) { setStickyVariables(sticky, false); } @@ -558,10 +562,13 @@ private Featurevisor.OverrideOptions mergeOverrideOptions(Featurevisor.OverrideO if (options == null) { options = new Featurevisor.OverrideOptions(); } - - options.setInternalStickyFeatures(this.sticky); - options.setInternalStickyVariables(this.stickyVariables); - - return options; + Featurevisor.OverrideOptions merged = new Featurevisor.OverrideOptions() + .defaultVariationValue(options.getDefaultVariationValue()); + if (options.hasDefaultVariableValue()) merged.defaultVariableValue(options.getDefaultVariableValue()); + merged.setInternalStickyFeatures(this.sticky); + merged.setInternalStickyVariables(this.stickyVariables); + merged.setInternalStickySource(() -> new EvaluateOptions.StickyState( + this.sticky, this.stickyVariables, this.parent.getStateGeneration() + ":" + this.stateGeneration.get())); + return merged; } } diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/CompareVersions.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/CompareVersions.java index 7cf44c3..96a16fd 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/CompareVersions.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/CompareVersions.java @@ -9,7 +9,9 @@ * Utility class for comparing semantic versions * Based on the TypeScript implementation from: https://github.com/omichelsen/compare-versions */ -final class CompareVersions { +public final class CompareVersions { + + private CompareVersions() {} private static final Pattern SEMVER_PATTERN = Pattern.compile( "^[v^~<>=]*?(\\d+)(?:\\.([x*]|\\d+)(?:\\.([x*]|\\d+)(?:\\.([x*]|\\d+))?(?:-([\\da-z\\-]+(?:\\.[\\da-z\\-]+)*))?(?:\\+[\\da-z\\-]+(?:\\.[\\da-z\\-]+)*)?)?)?$", @@ -27,17 +29,17 @@ public static int compareVersions(String v1, String v2) { List n1 = validateAndParse(v1); List n2 = validateAndParse(v2); - // pop off the patch + // Separate the prerelease from the core components String p1 = n1.size() > 0 ? n1.remove(n1.size() - 1) : null; String p2 = n2.size() > 0 ? n2.remove(n2.size() - 1) : null; // validate numbers - int r = compareSegments(n1, n2); + int r = compareSegments(n1, n2, false); if (r != 0) return r; // validate pre-release if (p1 != null && p2 != null) { - return compareSegments(splitByDot(p1), splitByDot(p2)); + return compareSegments(splitByDot(p1), splitByDot(p2), true); } else if (p1 != null || p2 != null) { return p1 != null ? -1 : 1; } @@ -63,51 +65,51 @@ private static List validateAndParse(String version) { } private static boolean isWildcard(String s) { - return "*".equals(s) || "x".equalsIgnoreCase(s) || "X".equals(s); + return "*".equals(s) || "x".equals(s) || "X".equals(s); } - private static Object[] forceType(Object a, Object b) { - if (a.getClass() != b.getClass()) { - return new Object[]{String.valueOf(a), String.valueOf(b)}; + private static boolean isNumeric(String value) { + if (value.isEmpty()) return false; + for (int i = 0; i < value.length(); i++) { + char character = value.charAt(i); + if (character < '0' || character > '9') return false; } - return new Object[]{a, b}; + return true; } - private static Object tryParse(String v) { - try { - int n = Integer.parseInt(v); - return n; - } catch (NumberFormatException e) { - return v; - } + private static int significantStart(String value) { + int start = 0; + while (start < value.length() && value.charAt(start) == '0') start++; + return start; } - private static int compareStrings(String a, String b) { - if (isWildcard(a) || isWildcard(b)) return 0; - - Object[] forced = forceType(tryParse(a), tryParse(b)); - Object ap = forced[0]; - Object bp = forced[1]; - - if (ap instanceof Number && bp instanceof Number) { - int aNum = ((Number) ap).intValue(); - int bNum = ((Number) bp).intValue(); - if (aNum > bNum) return 1; - if (aNum < bNum) return -1; + private static int compareStrings(String a, String b, boolean prerelease) { + if (!prerelease && (isWildcard(a) || isWildcard(b))) return 0; + boolean an = isNumeric(a), bn = isNumeric(b); + if (an && bn) { + int aStart = significantStart(a), bStart = significantStart(b); + int aLength = a.length() - aStart, bLength = b.length() - bStart; + if (aLength != bLength) return Integer.compare(aLength, bLength); + for (int i = 0; i < aLength; i++) { + int result = Character.compare(a.charAt(aStart + i), b.charAt(bStart + i)); + if (result != 0) return Integer.signum(result); + } return 0; - } else { - return ap.toString().compareTo(bp.toString()); + } else if (prerelease && an != bn) { + return an ? -1 : 1; } + return Integer.signum(a.compareTo(b)); } - private static int compareSegments(List a, List b) { + private static int compareSegments(List a, List b, boolean prerelease) { int maxLength = Math.max(a.size(), b.size()); for (int i = 0; i < maxLength; i++) { + if (prerelease && (i == a.size() || i == b.size())) return Integer.compare(a.size(), b.size()); String aVal = i < a.size() && a.get(i) != null ? a.get(i) : "0"; String bVal = i < b.size() && b.get(i) != null ? b.get(i) : "0"; - int r = compareStrings(aVal, bVal); + int r = compareStrings(aVal, bVal, prerelease); if (r != 0) return r; } diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Conditions.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Conditions.java index 313dc35..b343c56 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Conditions.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Conditions.java @@ -8,13 +8,41 @@ import java.util.regex.Pattern; import java.time.Instant; import java.time.OffsetDateTime; -import java.time.format.DateTimeParseException; +import java.time.ZonedDateTime; /** * Conditions utility for Featurevisor SDK * Provides condition matching functionality */ public class Conditions { + private static final Instant MIN_PORTABLE_DATE = Instant.parse("0001-01-01T00:00:00Z"); + private static final Instant MAX_PORTABLE_DATE_EXCLUSIVE = Instant.parse("+10000-01-01T00:00:00Z"); + + static String normalizePortableRegex(String pattern) { + StringBuilder output = new StringBuilder(); + boolean inClass = false; + for (int index = 0; index < pattern.length(); index++) { + char current = pattern.charAt(index); + if (current == '\\' && index + 1 < pattern.length()) { + char code = pattern.charAt(++index); + String replacement = null; + switch (code) { + case 'd': replacement = inClass ? "0-9" : "[0-9]"; break; + case 'D': replacement = inClass ? "\\x{0}-\\x{2F}\\x{3A}-\\x{10FFFF}" : "[^0-9]"; break; + case 'w': replacement = inClass ? "A-Za-z0-9_" : "(?-i:[A-Za-z0-9_])"; break; + case 'W': replacement = inClass ? "\\x{0}-\\x{2F}\\x{3A}-\\x{40}\\x{5B}-\\x{5E}\\x{60}\\x{7B}-\\x{10FFFF}" : "(?-i:[^A-Za-z0-9_])"; break; + default: break; + } + if (replacement == null) output.append('\\').append(code); + else output.append(replacement); + continue; + } + if (current == '[' && !inClass) inClass = true; + else if (current == ']' && inClass) inClass = false; + output.append(current); + } + return output.toString(); + } /** * Functional interface for getting regex patterns. */ @@ -133,18 +161,37 @@ static boolean conditionIsMatched( Object contextValue = ContextUtils.getValueFromContext(context, attribute); + return valueIsMatched(contextValue, pathExists(context, attribute), operator, value, regexFlags, getRegex); + } + + /** Shared comparison semantics for context conditions and variable requirements. */ + static boolean valueIsMatched( + Object contextValue, + boolean present, + Operator operator, + Object value, + String regexFlags, + GetRegex getRegex) { switch (operator) { + case IS_NULL: + return present && contextValue == null; + case NOT_NULL: + return present && contextValue != null; + case EMPTY: + return present && empty(contextValue, true); + case NOT_EMPTY: + return present && empty(contextValue, false); case EQUALS: - return pathExists(context, attribute) && equals(contextValue, value); + return present && equals(contextValue, value); case NOT_EQUALS: - return !pathExists(context, attribute) || !equals(contextValue, value); + return !present || !equals(contextValue, value); case IN: - return pathExists(context, attribute) && in(contextValue, value); + return present && in(contextValue, value); case NOT_IN: // Match PHP implementation: check if value is array and context value is string/numeric/null if (value instanceof List && (contextValue instanceof String || contextValue instanceof Number || contextValue == null) && - pathExists(context, attribute)) { + present) { return !in(contextValue, value); } return false; @@ -165,15 +212,25 @@ static boolean conditionIsMatched( case LESS_THAN_OR_EQUALS: return lessThanOrEquals(contextValue, value); case EXISTS: - return pathExists(context, attribute); + return present; case NOT_EXISTS: - return !pathExists(context, attribute); + return !present; case MATCHES: return matches(contextValue, value, regexFlags, getRegex); case NOT_MATCHES: return contextValue instanceof String && value instanceof String && !matches(contextValue, value, regexFlags, getRegex); case INCLUDES: return includes(contextValue, value); + case INCLUDES_ANY: + case NOT_INCLUDES_ANY: + if (!present || !(contextValue instanceof List) || !(value instanceof List)) return false; + for (Object candidate : (List) value) if (!isPrimitive(candidate)) return false; + for (Object candidate : (List) value) { + for (Object item : (List) contextValue) { + if (equals(item, candidate)) return operator == Operator.INCLUDES_ANY; + } + } + return operator == Operator.NOT_INCLUDES_ANY; case NOT_INCLUDES: return contextValue instanceof List && isPrimitive(value) && !includes(contextValue, value); case SEMVER_EQUALS: @@ -203,6 +260,14 @@ static boolean conditionIsMatched( * instance evaluation data provider. */ public static boolean allConditionsAreMatched(Object conditions, Map context) { + return allConditionsAreMatched(conditions, context, null); + } + + /** + * Match conditions with a caller selected diagnostic level. + */ + public static boolean allConditionsAreMatched(Object conditions, Map context, + FeaturevisorLogLevel logLevel) { DatafileContent datafile = new DatafileContent(); datafile.setSchemaVersion("2"); datafile.setRevision("matcher"); @@ -211,7 +276,8 @@ public static boolean allConditionsAreMatched(Object conditions, Map) value).isEmpty(); + } else if (value instanceof Map) { + isEmpty = ((Map) value).isEmpty(); + } else { + return false; + } + return isEmpty == expectedEmpty; + } + private static boolean in(Object contextValue, Object conditionValue) { if (!(conditionValue instanceof List)) { return false; @@ -391,19 +471,49 @@ private static boolean after(Object contextValue, Object conditionValue) { } private static Instant parseDate(Object value) { - if (value instanceof Instant) { - return (Instant) value; - } - if (value instanceof Date) { - return ((Date) value).toInstant(); + try { + return parsePortableDate(value); + } catch (RuntimeException ignored) { + // Native Date implementations can reject conversion to an instant. + return null; } - if (value instanceof String) { - try { - return OffsetDateTime.parse((String) value).toInstant(); - } catch (DateTimeParseException ignored) { + } + + private static Instant parsePortableDate(Object value) { + Instant parsed; + if (value instanceof Instant) { + parsed = (Instant) value; + } else if (value instanceof OffsetDateTime) { + OffsetDateTime date = (OffsetDateTime) value; + if (date.getYear() < 1 || date.getYear() > 9999) return null; + if (Math.abs(date.getOffset().getTotalSeconds()) > 14 * 3600) return null; + parsed = date.toInstant(); + } else if (value instanceof ZonedDateTime) { + ZonedDateTime date = (ZonedDateTime) value; + if (date.getYear() < 1 || date.getYear() > 9999) return null; + if (Math.abs(date.getOffset().getTotalSeconds()) > 14 * 3600) return null; + parsed = date.toInstant(); + } else if (value instanceof Date) { + parsed = ((Date) value).toInstant(); + } else if (value instanceof String) { + String text = (String) value; + if (!text.matches("[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})")) { return null; } - } - return null; + if (text.startsWith("0000-")) return null; + if (!text.endsWith("Z")) { + int hour = Integer.parseInt(text.substring(text.length() - 5, text.length() - 3)); + int minute = Integer.parseInt(text.substring(text.length() - 2)); + if (hour > 14 || minute > 59 || (hour == 14 && minute != 0)) return null; + } + OffsetDateTime date = OffsetDateTime.parse(text); + if (Math.abs(date.getOffset().getTotalSeconds()) > 14 * 3600) return null; + parsed = date.toInstant(); + } else { + return null; + } + if (parsed.isBefore(MIN_PORTABLE_DATE) || + !parsed.isBefore(MAX_PORTABLE_DATE_EXCLUSIVE)) return null; + return parsed.truncatedTo(java.time.temporal.ChronoUnit.MILLIS); } } diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/DatafileContent.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/DatafileContent.java index 60faf12..4ad75a8 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/DatafileContent.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/DatafileContent.java @@ -98,7 +98,9 @@ public void setFeatures(Map features) { public static DatafileContent fromJson(String jsonString) throws Exception { ObjectMapper mapper = new ObjectMapper(); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - return mapper.readValue(jsonString, DatafileContent.class); + return mapper.readerFor(DatafileContent.class) + .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .readValue(jsonString); } /** diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Dependencies.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Dependencies.java new file mode 100644 index 0000000..606755e --- /dev/null +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Dependencies.java @@ -0,0 +1,159 @@ +package com.featurevisor.sdk; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.DeserializationFeature; +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; + +/** Structural mixed graph indexing, without reading normalized entity caches. */ +final class Dependencies { + private static final ObjectMapper JSON = new ObjectMapper() + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + static final class Index { + final Map>[] dependents; + final Map> owners = new HashMap<>(); + private final Set writableSources = new HashSet<>(); + int extractedEntities; + @SuppressWarnings("unchecked") + Index() { + dependents = new Map[3]; + for (int index = 0; index < 3; index++) dependents[index] = new HashMap<>(); + } + Index(Index previous) { + this(); + for (int kind = 0; kind < 3; kind++) dependents[kind].putAll(previous.dependents[kind]); + owners.putAll(previous.owners); + } + Set writable(int kind, String key) { + String source = id(kind, key); + Set values = dependents[kind].get(key); + if (writableSources.add(source) || values == null) { + values = values == null ? new HashSet<>() : new HashSet<>(values); + dependents[kind].put(key, values); + } + return values; + } + void remove(String owner) { + for (String source : owners.getOrDefault(owner, Set.of())) { + int kind = source.charAt(0) - '0'; + String key = source.substring(2); + Set next = writable(kind, key); + next.remove(owner); + if (next.isEmpty()) dependents[kind].remove(key); + else dependents[kind].put(key, next); + } + owners.remove(owner); + } + } + private static String id(int kind, String key) { return kind + ":" + key; } + private static void add(Index index, int kind, String key, String dependent) { + if (key != null) { + index.writable(kind, key).add(dependent); + index.owners.computeIfAbsent(dependent, ignored -> new HashSet<>()).add(id(kind, key)); + } + } + private static void visit(Index index, Object value, String dependent) { + if (value == null || value == Normalize.INVALID || "*".equals(value)) return; + if (value instanceof String) { + String text = (String) value; + if (Normalize.stringified(text)) { + try { visit(index, JSON.readValue(text, Object.class), dependent); } + catch (Exception ignored) { } + } else add(index, 2, text, dependent); + } else if (value instanceof List) { + for (Object child : (List) value) visit(index, child, dependent); + } else if (value instanceof Map) { + Map map = (Map) value; + if (map.get("variable") instanceof String) { + if (map.get("feature") instanceof String) add(index, 0, (String) map.get("feature"), dependent); + else add(index, 1, (String) map.get("variable"), dependent); + } else if (map.get("feature") instanceof String) add(index, 0, (String) map.get("feature"), dependent); + else if (!map.containsKey("attribute")) { + for (String group : List.of("and", "or", "not")) if (map.containsKey(group)) visit(index, map.get(group), dependent); + } + } + } + private static void selectors(Index index, Object conditions, Object segments, List required, String dependent) { + visit(index, conditions, dependent); + visit(index, segments, dependent); + legacy(index, required, dependent); + } + private static void legacy(Index index, Object value, String dependent) { + if (value instanceof String) add(index, 0, (String) value, dependent); + else if (value instanceof List) for (Object child : (List) value) legacy(index, child, dependent); + else if (value instanceof Map) { + Map map = (Map) value; + if (map.get("feature") instanceof String) add(index, 0, (String) map.get("feature"), dependent); + else for (String group : List.of("and", "or", "not")) legacy(index, map.get(group), dependent); + } + } + private static void overrides(Index index, List values, String dependent) { + if (values != null) for (VariableOverride value : values) { + selectors(index, value.getConditions(), value.getSegments(), value.getRequiredFeatures(), dependent); + } + } + private static void groups(Index index, Map> values, String dependent) { + if (values != null) for (List entries : values.values()) overrides(index, entries, dependent); + } + static Index create(DatafileContent datafile) { + return update(new Index(), new DatafileContent(), datafile); + } + static Index update(Index previous, DatafileContent before, DatafileContent datafile) { + Index index = new Index(previous); + updateOwners(index, 2, before.getSegments(), datafile.getSegments(), (key, value) -> visit(index, value.getConditions(), id(2, key))); + updateOwners(index, 0, before.getFeatures(), datafile.getFeatures(), (key, value) -> { + String dependent = id(0, key); + selectors(index, value.getConditions(), null, value.getRequiredFeatures(), dependent); + if (value.getForce() != null) for (Force force : value.getForce()) selectors(index, force.getConditions(), force.getSegments(), force.getRequiredFeatures(), dependent); + if (value.getTraffic() != null) for (Traffic traffic : value.getTraffic()) { + selectors(index, traffic.getConditions(), traffic.getSegments(), traffic.getRequiredFeatures(), dependent); + groups(index, traffic.getVariableOverrides(), dependent); + } + if (value.getVariations() != null) for (Variation variation : value.getVariations()) groups(index, variation.getVariableOverrides(), dependent); + }); + updateOwners(index, 1, before.getVariables(), datafile.getVariables(), (key, value) -> { + String dependent = id(1, key); + selectors(index, value.getConditions(), null, value.getRequiredFeatures(), dependent); + Object compose = value.getCompose(); + if (compose instanceof String) add(index, 1, (String) compose, dependent); + else if (compose instanceof List) for (Object entry : (List) compose) visit(index, entry, dependent); + overrides(index, value.getOverrideProgram() != null ? value.getOverrideProgram() : value.getOverrides(), dependent); + }); + return index; + } + private static void updateOwners(Index index, int kind, Map before, Map after, + java.util.function.BiConsumer extract) { + Map old = before == null ? Map.of() : before; + Map current = after == null ? Map.of() : after; + old.forEach((key, value) -> { + if (current.get(key) != value) index.remove(id(kind, key)); + }); + current.forEach((key, value) -> { + if (old.get(key) != value) { + index.extractedEntities++; + extract.accept(key, value); + } + }); + } + static void addChanges(Set features, Set variables, Set changedSegments, Index index) { + Set seen = new HashSet<>(); + Queue queue = new ArrayDeque<>(); + features.forEach(key -> queue.add(id(0, key))); + variables.forEach(key -> queue.add(id(1, key))); + changedSegments.forEach(key -> queue.add(id(2, key))); + while (!queue.isEmpty()) { + String current = queue.remove(); + if (!seen.add(current)) continue; + int kind = current.charAt(0) - '0'; + String key = current.substring(2); + if (kind == 0) features.add(key); + if (kind == 1) variables.add(key); + for (String dependent : index.dependents[kind].getOrDefault(key, Set.of())) queue.add(dependent); + } + } +} diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/DependencyOf.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/DependencyOf.java new file mode 100644 index 0000000..de274e9 --- /dev/null +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/DependencyOf.java @@ -0,0 +1,34 @@ +package com.featurevisor.sdk; + +import java.util.Objects; + +/** The immediate evaluation requesting a prerequisite getter. */ +public final class DependencyOf { + private final String type; + private final String featureKey; + private final String variableKey; + + public DependencyOf(String type, String featureKey, String variableKey) { + this.type = type; + this.featureKey = featureKey; + this.variableKey = variableKey; + } + + static DependencyOf from(EvaluateOptions options) { + return new DependencyOf(options.getType(), options.isGlobalVariable() ? null : options.getFeatureKey(), options.getVariableKey()); + } + + public String getType() { return type; } + public String getFeatureKey() { return featureKey; } + public String getVariableKey() { return variableKey; } + + @Override public boolean equals(Object other) { + if (!(other instanceof DependencyOf)) return false; + DependencyOf value = (DependencyOf) other; + return Objects.equals(type, value.type) + && Objects.equals(featureKey, value.featureKey) + && Objects.equals(variableKey, value.variableKey); + } + + @Override public int hashCode() { return Objects.hash(type, featureKey, variableKey); } +} diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Emitter.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Emitter.java index 8c1aa43..838e1e2 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Emitter.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Emitter.java @@ -7,7 +7,7 @@ final class Emitter { private final Map> listeners = - new HashMap<>(); + new java.util.concurrent.ConcurrentHashMap<>(); FeaturevisorUnsubscribe on( FeaturevisorEventName eventName, diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Evaluate.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Evaluate.java index fc134af..bddc6ce 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Evaluate.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Evaluate.java @@ -6,12 +6,17 @@ import java.util.Map; import java.util.HashMap; import java.util.List; +import java.util.ArrayList; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.DeserializationFeature; /** * Main evaluation logic for Featurevisor SDK * Handles the evaluation of features, variations, and variables */ final class Evaluate { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper() + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); /** * Evaluate with modules @@ -19,17 +24,48 @@ final class Evaluate { * @return The evaluation result */ public static Evaluation evaluateWithModules(EvaluateOptions opts) { + EvaluationSession session = opts.getSession(); + boolean ownsRoot = session.beginRoot(DependencyOf.from(opts)); + session.retainCallbackOptions(opts); try { ModulesManager modulesManager = opts.getModulesManager(); // run before modules EvaluateOptions options = opts; if (modulesManager != null) { - options = modulesManager.executeBeforeModules(options); + for (FeaturevisorModule module : modulesManager.phaseModules()) { + if (module.getBeforeEvaluation() != null) { + Map previousFeatures = options.getStickyFeatures(); + Map previousVariables = options.getStickyVariables(); + EvaluateOptions.StickySource source = options.getStickySource(); + boolean inheritedFeatures = options.inheritsStickyFeatures(); + boolean inheritedVariables = options.inheritsStickyVariables(); + options = module.getBeforeEvaluation().apply(options); + session.retainCallbackOptions(options); + options.setSession(session); + options.setDependencyOf(opts.getDependencyOf()); + options.setDependencyPurpose(opts.getDependencyPurpose()); + if (options.getStickySource() == null) options.setStickySource(source, inheritedFeatures, inheritedVariables); + if (options.getStickyFeatures() != previousFeatures) options.setInheritedStickyFeatures(false); + if (options.getStickyVariables() != previousVariables) options.setInheritedStickyVariables(false); + } + } } + options.refreshSticky(); + session.updateCurrent(DependencyOf.from(options)); // evaluate Evaluation evaluation = evaluate(options); + session.throwIfFatal(); + if (options.isGlobalVariable() && evaluation.getVariable() != null + && Boolean.TRUE.equals(evaluation.getVariable().getDeprecated()) + && !Evaluation.REASON_STICKY.equals(evaluation.getReason())) { + Map details = new HashMap<>(); + details.put("code", "deprecated_variable"); + details.put("variableKey", options.getVariableKey()); + details.put("evaluation", evaluation); + options.getDiagnostics().warn("Variable is deprecated", details); + } // default: variation if (options.getDefaultVariationValue() != null && @@ -49,9 +85,19 @@ public static Evaluation evaluateWithModules(EvaluateOptions opts) { if (modulesManager != null) { evaluation = modulesManager.executeAfterModules(evaluation, options); } + session.throwIfFatal(); return evaluation; - } catch (Exception e) { + } catch (Exception | StackOverflowError thrown) { + // A small thread stack fails the evaluation like the logical depth bound, + // instead of escaping a public getter. + Exception e = thrown instanceof StackOverflowError + ? new EvaluationSession.RequirementFailure(new IllegalStateException( + "Evaluation dependency depth exceeded the available thread stack")) + : (Exception) thrown; + if (e instanceof EvaluationSession.RequirementFailure) { + session.recordRequirementFailure((EvaluationSession.RequirementFailure) e); + } String type = opts.getType(); String featureKey = opts.getFeatureKey(); String variableKey = opts.getVariableKey(); @@ -59,15 +105,25 @@ public static Evaluation evaluateWithModules(EvaluateOptions opts) { Evaluation evaluation = new Evaluation(type, featureKey, variableKey) .reason(Evaluation.REASON_ERROR) + .variableKey(variableKey) .error(e); Map details = new HashMap<>(); + details.put("code", "evaluation_error"); + details.put("originalError", e); details.put("featureKey", featureKey); details.put("variableKey", variableKey); - details.put("error", e.getMessage()); - diagnostics.error("error during evaluation", details); + details.put("reason", Evaluation.REASON_ERROR); + details.put("evaluation", evaluation); + // A dependency failure fails the complete public call. Nested getters rethrow + // it, so only the call that started the session reports it. + boolean dependencyFailure = e instanceof EvaluationSession.Cycle + || e instanceof EvaluationSession.RequirementFailure; + if (ownsRoot || !dependencyFailure) diagnostics.error("Error during evaluation", details); return evaluation; + } finally { + if (ownsRoot) session.endRoot(); } } @@ -84,15 +140,15 @@ public static Evaluation evaluate(EvaluateOptions options) { Evaluation evaluation; - try { - if (options.isGlobalVariable()) { - return evaluateGlobalVariable(options); - } + if (options.isGlobalVariable()) { + return evaluateGlobalVariable(options); + } // root Evaluation flag; if (!Evaluation.TYPE_FLAG.equals(type)) { // needed by variation and variable evaluations + // The flag check is part of the outer getter, not a new requester. flag = evaluate(options.copy().type(Evaluation.TYPE_FLAG)); Evaluation disabledEvaluation = EvaluateDisabled.evaluateDisabled(options, flag); @@ -125,21 +181,11 @@ public static Evaluation evaluate(EvaluateOptions options) { return forcedResult.getEvaluation(); } - // required (only for flag evaluations) - if (Evaluation.TYPE_FLAG.equals(type)) { - if (feature.getRequiredFeatures() != null) { - if (!requiredFeaturesAreMatched(feature.getRequiredFeatures(), options)) { - return new Evaluation(type, featureKey, variableKey) - .reason(Evaluation.REASON_REQUIRED) - .requiredFeatures(feature.getRequiredFeatures()) - .enabled(false); - } - } else { - Evaluation requiredEvaluation = evaluateRequired(options, feature); - if (requiredEvaluation != null) { - return requiredEvaluation; - } - } + if (Evaluation.TYPE_FLAG.equals(type) && feature.hasConditions() + && !conditionsAreMatched(feature.getConditions(), options)) { + return new Evaluation(type, featureKey, variableKey) + .reason(Evaluation.REASON_CONDITIONS_UNMET) + .conditions(feature.getConditions()).enabled(false); } // bucket @@ -173,6 +219,7 @@ public static Evaluation evaluate(EvaluateOptions options) { // nothing matched evaluation = new Evaluation(type, featureKey, variableKey) .reason(Evaluation.REASON_NO_MATCH) + .variableKey(variableKey) .bucketKey(bucketKey) .bucketValue(bucketValue) .enabled(false); @@ -182,212 +229,318 @@ public static Evaluation evaluate(EvaluateOptions options) { details.put("bucketKey", bucketKey); details.put("bucketValue", bucketValue); diagnostics.debug("nothing matched", details); + return evaluation; + } - return evaluation; - } catch (Exception e) { - evaluation = new Evaluation(type, featureKey, variableKey) - .reason(Evaluation.REASON_ERROR) - .error(e); + private static final class InvalidSelector extends RuntimeException {} - Map details = new HashMap<>(); - details.put("featureKey", featureKey); - details.put("variableKey", variableKey); - details.put("error", e.getMessage()); - diagnostics.error("error during evaluation", details); + static boolean conditionsAreMatched(Object expression, EvaluateOptions options) { + try { return condition(expression, options); } + catch (InvalidSelector error) { return false; } + } - return evaluation; + private static boolean attributeOnly(Object expression) { + if (expression == Normalize.INVALID) return false; + if (expression instanceof Condition) return true; + if (expression instanceof List) { + for (Object child : (List) expression) if (!attributeOnly(child)) return false; + return true; + } + if (!(expression instanceof Map)) return false; + Map value = (Map) expression; + if (value.containsKey("variable") || value.containsKey("feature")) return false; + if (value.containsKey("attribute")) return true; + for (String group : List.of("and", "or", "not")) { + if (value.containsKey(group)) return value.get(group) instanceof List && attributeOnly(value.get(group)); + } + return false; + } + + private static boolean condition(Object expression, EvaluateOptions options) { + if (expression == Normalize.INVALID) throw new InvalidSelector(); + if ("*".equals(expression)) return true; + if (expression instanceof String) { + String key = (String) expression; + Segment segment = options.getCurrentDatafile().getSegment(key); + if (segment == null) return false; + if (segment.getConditions() == Normalize.INVALID) throw new InvalidSelector(); + if (attributeOnly(segment.getConditions())) return condition(segment.getConditions(), options); + return options.getSession().segment(key, () -> condition(segment.getConditions(), options)); + } + if (expression instanceof List) { + List entries = (List) expression; + if (entries.isEmpty()) return false; + for (Object entry : entries) if (!condition(entry, options)) return false; + return true; + } + if (expression instanceof Condition) { + return options.getInstanceEvaluationDataProvider().allConditionsAreMatched(expression, options.getContext()); + } + if (!(expression instanceof Map)) return false; + Map value = (Map) expression; + if (value.containsKey("variable")) { + try { return variableCondition(value, options); } + catch (EvaluationSession.Cycle | EvaluationSession.RequirementFailure error) { throw error; } + catch (RuntimeException error) { throw new EvaluationSession.RequirementFailure(error); } + } + if (value.containsKey("feature")) { + if (!(value.get("feature") instanceof String)) return false; + EvaluateOptions clean = options.copy().featureKey((String) value.get("feature")) + .globalVariable(false).variableKey(null).defaultVariationValue(null) + .defaultVariableValue(null, false); + boolean enabled = !value.containsKey("enabled") || Boolean.TRUE.equals(value.get("enabled")); + if (Boolean.TRUE.equals(readDependency(clean.type(Evaluation.TYPE_FLAG))) != enabled) return false; + return !value.containsKey("variation") + || java.util.Objects.equals(value.get("variation"), readDependency(clean.copy().type(Evaluation.TYPE_VARIATION))); } + if (value.containsKey("attribute")) { + return options.getInstanceEvaluationDataProvider().allConditionsAreMatched(expression, options.getContext()); + } + for (String group : List.of("and", "or", "not")) { + if (!value.containsKey(group)) continue; + if (!(value.get(group) instanceof List) || ((List) value.get(group)).isEmpty()) return false; + boolean result = !"or".equals(group); + for (Object child : (List) value.get(group)) { + boolean matched = condition(child, options); + if ("or".equals(group) && matched) return true; + if (!"or".equals(group) && !matched) { result = false; break; } + } + return "not".equals(group) ? !result : result; + } + return false; } - static boolean requiredFeaturesAreMatched(List requirements, EvaluateOptions options) { - if (requirements == null || requirements.isEmpty()) { return true; } + private static boolean variableCondition(Object required, EvaluateOptions options) { + if (!(required instanceof Map)) return false; + Map map = (Map) required; + Object variableValue = map.get("variable"); + if (!(variableValue instanceof String)) return false; + String variableKey = (String) variableValue; + Object featureValue = map.get("feature"); + Object value; EvaluateOptions clean = options.copy() .defaultVariableValue(null, false) .defaultVariationValue(null) - .globalVariable(false); - clean.setVariableKey(null); - for (Object item : requirements) { - String key; - boolean enabled = true; - String variation = null; - if (item instanceof String) { - key = (String) item; - } else if (item instanceof Map) { - @SuppressWarnings("unchecked") Map value = (Map) item; - key = (String) value.get("feature"); - if (value.containsKey("enabled")) { enabled = Boolean.TRUE.equals(value.get("enabled")); } - variation = (String) value.get("variation"); - } else { return false; } - if (key == null) { return false; } - Evaluation flag = evaluateWithModules(clean.copy().type(Evaluation.TYPE_FLAG).featureKey(key)); - if (Boolean.TRUE.equals(flag.getEnabled()) != enabled) { return false; } - if (variation != null) { - Evaluation variationEvaluation = evaluateWithModules(clean.copy().type(Evaluation.TYPE_VARIATION).featureKey(key)); - String actualVariation = variationEvaluation.getVariationValue(); - if (actualVariation == null && variationEvaluation.getVariation() != null) { - actualVariation = variationEvaluation.getVariation().getValue(); + .type(Evaluation.TYPE_VARIABLE) + .variableKey(variableKey); + if (featureValue instanceof String) { + value = readDependency(clean.featureKey((String) featureValue).globalVariable(false)); + } else { + value = readDependency(clean.featureKey(null).globalVariable(true)); + } + boolean present = true; + if (map.containsKey("path")) { + Object pathValue = map.get("path"); + if (!(pathValue instanceof List)) throw new IllegalArgumentException("Variable requirement path must be compiled"); + for (Object part : (List) pathValue) { + if (!(part instanceof String) && !(part instanceof Number)) { + throw new IllegalArgumentException("Variable requirement path must be compiled"); + } + if (part instanceof String && List.of("__proto__", "prototype", "constructor").contains(part)) { + throw new IllegalArgumentException("Unsafe variable requirement path"); + } + if (part instanceof Number) { + double numeric = ((Number) part).doubleValue(); + if (!(value instanceof List) || numeric < 0 || numeric != Math.rint(numeric) + || numeric > Integer.MAX_VALUE || (int) numeric >= ((List) value).size()) { + present = false; + } else { + value = ((List) value).get((int) numeric); + } + } else { + String key = (String) part; + if (value instanceof List) { + if ("length".equals(key)) { + value = ((List) value).size(); + } else { + Integer index = arrayPropertyIndex(key); + if (index == null || index >= ((List) value).size()) present = false; + else value = ((List) value).get(index); + } + } else if (!(value instanceof Map) || !((Map) value).containsKey(key)) { + present = false; + } else { + value = ((Map) value).get(key); + } } - if (!variation.equals(actualVariation)) { return false; } + if (!present) { value = null; break; } } } - return true; + Object operatorValue = map.get("operator"); + if (!(operatorValue instanceof String)) return false; + Operator operator = Operator.fromValue((String) operatorValue); + String regexFlags = map.get("regexFlags") instanceof String ? (String) map.get("regexFlags") : ""; + return Conditions.valueIsMatched(value, present, operator, map.get("value"), regexFlags, + (pattern, flags) -> options.getInstanceEvaluationDataProvider().getRegex(pattern, flags)); + } + + private static Integer arrayPropertyIndex(String key) { + if (key.isEmpty() || (key.length() > 1 && key.charAt(0) == '0')) return null; + for (int index = 0; index < key.length(); index++) { + if (key.charAt(index) < '0' || key.charAt(index) > '9') return null; + } + try { + long value = Long.parseLong(key); + return value <= Integer.MAX_VALUE ? (int) value : null; + } catch (NumberFormatException ignored) { + return null; + } + } + + private static Object readDependency(EvaluateOptions requested) { + return readDependency(requested, "condition"); + } + + private static Object readDependency(EvaluateOptions requested, String purpose) { + requested.setDependencyPurpose(purpose); + // Keep the active getter's snapshot, but refresh each new dependency read. + requested.refreshDatafile(); + requested.refreshSticky(); + EvaluationSession session = requested.getSession(); + DependencyOf parent = session.current(); + return session.read(requested, () -> { + requested.setDependencyOf(parent); + Evaluation evaluation = evaluateWithModules(requested); + switch (requested.getType()) { + case Evaluation.TYPE_FLAG: return Boolean.TRUE.equals(evaluation.getEnabled()); + case Evaluation.TYPE_VARIATION: + return evaluation.getVariationValue() != null ? evaluation.getVariationValue() + : evaluation.getVariation() != null ? evaluation.getVariation().getValue() : null; + case Evaluation.TYPE_VARIABLE: return normalizeGetterValue(evaluation, requested.getDiagnostics()); + default: return null; + } + }); + } + + static Object normalizeGetterValue(Evaluation evaluation, DiagnosticReporter diagnostics) { + Object value = evaluation.getVariableValue(); + boolean json = evaluation.getVariableSchema() != null + && evaluation.getVariableSchema().getType() == VariableType.JSON; + if (!json && evaluation.getVariable() != null) json = evaluation.getVariable().getType() == VariableType.JSON; + if (json && value instanceof String && (evaluation.getVariable() == null || evaluation.getVariable().getCompose() == null)) { + try { return OBJECT_MAPPER.readValue((String) value, Object.class); } + catch (Exception error) { + Map details = new HashMap<>(); + details.put("code", "evaluation_error"); + details.put("originalError", error); + details.put("variableKey", evaluation.getVariableKey()); + if (evaluation.getFeatureKey() != null) details.put("featureKey", evaluation.getFeatureKey()); + diagnostics.error("getVariable failed", details); + return null; + } + } + return value; } static boolean variableOverrideIsMatched(VariableOverride override, EvaluateOptions options) { - InstanceEvaluationDataProvider data = options.getInstanceEvaluationDataProvider(); - Map context = options.getContext(); - if (override.getConditions() != null && !data.allConditionsAreMatched(data.parseConditionsIfStringified(override.getConditions()), context)) { return false; } - if (override.getSegments() != null && !data.allSegmentsAreMatched(data.parseSegmentsIfStringified(override.getSegments()), context)) { return false; } - if (override.getRequiredFeatures() != null && !requiredFeaturesAreMatched(override.getRequiredFeatures(), options)) { return false; } - return override.getConditions() != null || override.getSegments() != null || override.getRequiredFeatures() != null; + return conditionsAreMatched(override.getConditions(), options); } private static Evaluation evaluateGlobalVariable(EvaluateOptions options) { String key = options.getVariableKey(); + GlobalVariable variable = options.getInstanceEvaluationDataProvider().getGlobalVariable(key); if (options.getStickyVariables() != null && options.getStickyVariables().containsKey(key)) { return new Evaluation(Evaluation.TYPE_VARIABLE, null, key) - .reason(Evaluation.REASON_STICKY).variableValue(options.getStickyVariables().get(key)); + .reason(Evaluation.REASON_STICKY).variableKey(key) + .variableValue(options.getStickyVariables().get(key)).variable(variable); } - GlobalVariable variable = options.getInstanceEvaluationDataProvider().getGlobalVariable(key); if (variable == null) { - return new Evaluation(Evaluation.TYPE_VARIABLE, null, key).reason(Evaluation.REASON_VARIABLE_NOT_FOUND); + return new Evaluation(Evaluation.TYPE_VARIABLE, null, key) + .reason(Evaluation.REASON_VARIABLE_NOT_FOUND).variableKey(key); } - if (!requiredFeaturesAreMatched(variable.getRequiredFeatures(), options)) { - boolean useDefault = Boolean.TRUE.equals(variable.getUseDefaultWhenDisabled()); + boolean composed = variable.getCompose() != null; + if (variable.hasConditions() && !conditionsAreMatched(variable.getConditions(), options)) { + boolean useDefault = !composed && Boolean.TRUE.equals(variable.getUseDefaultWhenDisabled()); Evaluation evaluation = new Evaluation(Evaluation.TYPE_VARIABLE, null, key) - .reason(Evaluation.REASON_REQUIRED_FEATURES_UNMET).variable(variable) - .requiredFeatures(variable.getRequiredFeatures()); + .reason(Evaluation.REASON_CONDITIONS_UNMET).conditions(variable.getConditions()) + .variableKey(key).variable(variable); if (useDefault ? variable.hasDefaultValue() : variable.hasDisabledValue()) { evaluation.variableValue(useDefault ? variable.getDefaultValue() : variable.getDisabledValue()); - } - return evaluation; + } else if (composed) evaluation.variableValue(null); + return freshMap(evaluation, variable); } - if (variable.getOverrides() != null) { - for (int index = 0; index < variable.getOverrides().size(); index++) { - VariableOverride override = variable.getOverrides().get(index); + Object initial = composed ? compose(variable.getCompose(), options) : variable.getDefaultValue(); + List program = variable.getOverrideProgram(); + List overrides = program != null ? program : variable.getOverrides(); + if (overrides != null) { + Object accumulated = initial; + List> appliedPaths = program != null ? new ArrayList<>() : null; + Evaluation matchedEvaluation = null; + for (int index = 0; index < overrides.size();) { + VariableOverride override = overrides.get(index); if (variableOverrideIsMatched(override, options)) { - return new Evaluation(Evaluation.TYPE_VARIABLE, null, key) - .reason(Evaluation.REASON_VARIABLE_OVERRIDE_RULE).variable(variable) - .variableValue(override.getValue()).variableOverrideIndex(index) + if (program != null) { + accumulated = VariableMutations.apply(accumulated, override.getOperations()); + appliedPaths.add(override.getKeyPath() != null ? override.getKeyPath() : List.of(override.getKey())); + } + matchedEvaluation = new Evaluation(Evaluation.TYPE_VARIABLE, null, key) + .reason(Evaluation.REASON_VARIABLE_OVERRIDE_RULE).variableKey(key).variable(variable) + .variableValue(program != null ? accumulated : override.getValue()).variableOverrideIndex(index) .variableOverrideKey(override.getKey()).variableOverridePath(override.getKeyPath()); + if (program != null) matchedEvaluation.variableOverridePaths(new ArrayList<>(appliedPaths)); + if (program == null) return freshMap(matchedEvaluation, variable); } + boolean matched = matchedEvaluation != null && matchedEvaluation.getVariableOverrideIndex() == index; + int next = program == null ? index + 1 + : matched ? valueOrInvalid(override.getOnMatch()) : valueOrInvalid(override.getOnMiss()); + if (program != null && (next <= index || next > overrides.size())) { + throw new IllegalArgumentException("Invalid variable override program jump"); + } + index = next; } + if (matchedEvaluation != null) return freshMap(matchedEvaluation, variable); } Evaluation evaluation = new Evaluation(Evaluation.TYPE_VARIABLE, null, key) - .reason(Evaluation.REASON_VARIABLE_DEFAULT).variable(variable); - if (variable.hasDefaultValue()) evaluation.variableValue(variable.getDefaultValue()); + .reason(Evaluation.REASON_VARIABLE_DEFAULT).variableKey(key).variable(variable); + if (composed) evaluation.reason(Evaluation.REASON_VARIABLE_COMPOSED).variableValue(initial); + else if (variable.hasDefaultValue()) evaluation.variableValue(variable.getDefaultValue()); return evaluation; } - /** - * Evaluate required features - * @param options The evaluation options - * @param feature The feature to evaluate - * @return The evaluation result if required features are not met, null otherwise - */ - private static Evaluation evaluateRequired(EvaluateOptions options, Feature feature) { - String type = options.getType(); - String featureKey = options.getFeatureKey(); - String variableKey = options.getVariableKey(); - DiagnosticReporter diagnostics = options.getDiagnostics(); - InstanceEvaluationDataProvider evaluationData = options.getInstanceEvaluationDataProvider(); + private static Evaluation freshMap(Evaluation result, GlobalVariable variable) { + if (variable.getCompose() instanceof List && result.getVariableValue() instanceof Map) { + result.variableValue(new java.util.LinkedHashMap<>((Map) result.getVariableValue())); + } + return result; + } - // Check if required features are enabled - List requiredList = feature.getRequired(); - if (requiredList == null) { - requiredList = new java.util.ArrayList<>(); + private static Object compose(Object expression, EvaluateOptions options) { + EvaluateOptions clean = options.copy().featureKey(null).variableKey(null) + .defaultVariableValue(null, false).defaultVariationValue(null); + if (expression instanceof String && !((String) expression).isEmpty()) { + return readDependency(clean.type(Evaluation.TYPE_VARIABLE).variableKey((String) expression).globalVariable(true), "composition"); } - if (requiredList.isEmpty()) { - return null; + if (!(expression instanceof List) || ((List) expression).isEmpty()) { + throw new IllegalArgumentException("Invalid composition"); } - - for (Object required : requiredList) { - if (required instanceof String) { - String requiredFeatureKey = (String) required; - Feature requiredFeature = evaluationData.getFeature(requiredFeatureKey); - - if (requiredFeature == null) { - Evaluation evaluation = new Evaluation(type, featureKey, variableKey) - .reason(Evaluation.REASON_REQUIRED) - .enabled(false); - - Map details = new HashMap<>(); - details.put("featureKey", featureKey); - details.put("requiredFeatureKey", requiredFeatureKey); - diagnostics.debug("required feature not found", details); - - return evaluation; - } - - // Check if the required feature is enabled - Evaluation requiredEvaluation = evaluate(options.copy() - .type(Evaluation.TYPE_FLAG) - .featureKey(requiredFeatureKey)); - - if (!Boolean.TRUE.equals(requiredEvaluation.getEnabled())) { - Evaluation evaluation = new Evaluation(type, featureKey, variableKey) - .reason(Evaluation.REASON_REQUIRED) - .enabled(false); - - Map details = new HashMap<>(); - details.put("featureKey", featureKey); - details.put("requiredFeatureKey", requiredFeatureKey); - diagnostics.debug("required feature disabled", details); - - return evaluation; - } - } else if (required instanceof Map) { - @SuppressWarnings("unchecked") - Map requiredMap = (Map) required; - String requiredFeatureKey = (String) requiredMap.get("key"); - String requiredVariation = (String) requiredMap.get("variation"); - - if (requiredFeatureKey == null) { - continue; - } - - Feature requiredFeature = evaluationData.getFeature(requiredFeatureKey); - - if (requiredFeature == null) { - Evaluation evaluation = new Evaluation(type, featureKey, variableKey) - .reason(Evaluation.REASON_REQUIRED) - .enabled(false); - - Map details = new HashMap<>(); - details.put("featureKey", featureKey); - details.put("requiredFeatureKey", requiredFeatureKey); - diagnostics.debug("required feature not found", details); - - return evaluation; - } - - // Check if the required feature has the required variation - Evaluation requiredEvaluation = evaluate(options.copy() - .type(Evaluation.TYPE_VARIATION) - .featureKey(requiredFeatureKey)); - - Object variationValue = requiredEvaluation.getVariationValue(); - if (variationValue == null && requiredEvaluation.getVariation() != null) { - variationValue = requiredEvaluation.getVariation().getValue(); - } - - if (requiredVariation != null && !requiredVariation.equals(variationValue)) { - Evaluation evaluation = new Evaluation(type, featureKey, variableKey) - .reason(Evaluation.REASON_REQUIRED) - .enabled(false); - - Map details = new HashMap<>(); - details.put("featureKey", featureKey); - details.put("requiredFeatureKey", requiredFeatureKey); - details.put("requiredVariation", requiredVariation); - details.put("actualVariation", variationValue); - diagnostics.debug("required feature variation mismatch", details); - - return evaluation; - } + Map result = new java.util.LinkedHashMap<>(); + for (Object member : (List) expression) { + if (!(member instanceof Map)) throw new IllegalArgumentException("Invalid composition reference"); + Map entry = (Map) member; + Object key = entry.get("key"); + if (!(key instanceof String) || ((String) key).isEmpty() + || List.of("__proto__", "constructor", "prototype").contains(key)) { + throw new IllegalArgumentException("Invalid composition property"); } + EvaluateOptions source = clean.copy(); + if (entry.get("variable") instanceof String) { + source.type(Evaluation.TYPE_VARIABLE).variableKey((String) entry.get("variable")); + source.globalVariable(!(entry.get("feature") instanceof String)); + source.featureKey((String) entry.get("feature")); + } else if (entry.get("feature") instanceof String) { + source.featureKey((String) entry.get("feature")).globalVariable(false) + .type(Boolean.TRUE.equals(entry.get("variation")) ? Evaluation.TYPE_VARIATION : Evaluation.TYPE_FLAG); + } else throw new IllegalArgumentException("Invalid composition reference"); + result.put((String) key, readDependency(source, "composition")); } + return result; + } - return null; + private static int valueOrInvalid(Object value) { + if (!(value instanceof Number)) return -1; + double number = ((Number) value).doubleValue(); + if (!Double.isFinite(number) || number != Math.rint(number) + || number < Integer.MIN_VALUE || number > Integer.MAX_VALUE) return -1; + return (int) number; } } diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateByBucketing.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateByBucketing.java index 778d314..931e13e 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateByBucketing.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateByBucketing.java @@ -99,12 +99,12 @@ public static EvaluateByBucketingResult evaluateByBucketing( Allocation matchedAllocation = null; if (!Evaluation.TYPE_FLAG.equals(type)) { - matchedTraffic = evaluationData.getMatchedTraffic(feature.getTraffic(), context); + matchedTraffic = evaluationData.getMatchedTraffic(feature.getTraffic(), options); if (matchedTraffic != null) { matchedAllocation = evaluationData.getMatchedAllocation(matchedTraffic, bucketValue); } } else { - matchedTraffic = evaluationData.getMatchedTraffic(feature.getTraffic(), context); + matchedTraffic = evaluationData.getMatchedTraffic(feature.getTraffic(), options); } @@ -120,6 +120,7 @@ public static EvaluateByBucketingResult evaluateByBucketing( if (matchedTraffic.getPercentage() == 0) { Evaluation evaluation = new Evaluation(type, featureKey, variableKey) .reason(Evaluation.REASON_RULE) + .variableKey(variableKey) .bucketKey(bucketKey) .bucketValue(bucketValue) .ruleKey(matchedTraffic.getKey()) @@ -138,58 +139,11 @@ public static EvaluateByBucketingResult evaluateByBucketing( // flag if (Evaluation.TYPE_FLAG.equals(type)) { - // flag: check if mutually exclusive - if (feature.getRanges() != null && !feature.getRanges().isEmpty()) { - // Find matched range - boolean matchedRange = false; - for (Range range : feature.getRanges()) { - if (bucketValue >= range.getStart() && bucketValue < range.getEnd()) { - matchedRange = true; - break; - } - } - - // matched - if (matchedRange) { - Evaluation evaluation = new Evaluation(type, featureKey, variableKey) - .reason(Evaluation.REASON_ALLOCATED) - .bucketKey(bucketKey) - .bucketValue(bucketValue) - .ruleKey(matchedTraffic.getKey()) - .traffic(convertTrafficToMap(matchedTraffic)) - .enabled(matchedTraffic.getEnabled() == null ? true : matchedTraffic.getEnabled()); - - Map details = new HashMap<>(); - details.put("featureKey", featureKey); - details.put("bucketKey", bucketKey); - details.put("bucketValue", bucketValue); - diagnostics.debug("matched", details); - - result.setEvaluation(evaluation); - return result; - } - - // no match - Evaluation evaluation = new Evaluation(type, featureKey, variableKey) - .reason(Evaluation.REASON_OUT_OF_RANGE) - .bucketKey(bucketKey) - .bucketValue(bucketValue) - .enabled(false); - - Map details = new HashMap<>(); - details.put("featureKey", featureKey); - details.put("bucketKey", bucketKey); - details.put("bucketValue", bucketValue); - diagnostics.debug("not matched", details); - - result.setEvaluation(evaluation); - return result; - } - // flag: override from rule if (matchedTraffic.getEnabled() != null) { Evaluation evaluation = new Evaluation(type, featureKey, variableKey) .reason(Evaluation.REASON_RULE) + .variableKey(variableKey) .bucketKey(bucketKey) .bucketValue(bucketValue) .ruleKey(matchedTraffic != null ? matchedTraffic.getKey() : null) @@ -210,6 +164,7 @@ public static EvaluateByBucketingResult evaluateByBucketing( if (bucketValue <= matchedTraffic.getPercentage()) { Evaluation evaluation = new Evaluation(type, featureKey, variableKey) .reason(Evaluation.REASON_RULE) + .variableKey(variableKey) .bucketKey(bucketKey) .bucketValue(bucketValue) .ruleKey(matchedTraffic.getKey()) @@ -242,6 +197,7 @@ public static EvaluateByBucketingResult evaluateByBucketing( if (variation != null) { Evaluation evaluation = new Evaluation(type, featureKey, variableKey) .reason(Evaluation.REASON_RULE) + .variableKey(variableKey) .bucketKey(bucketKey) .bucketValue(bucketValue) .ruleKey(matchedTraffic.getKey()) @@ -272,6 +228,7 @@ public static EvaluateByBucketingResult evaluateByBucketing( if (variation != null) { Evaluation evaluation = new Evaluation(type, featureKey, variableKey) .reason(Evaluation.REASON_ALLOCATED) + .variableKey(variableKey) .bucketKey(bucketKey) .bucketValue(bucketValue) .ruleKey(matchedTraffic.getKey()) @@ -304,6 +261,7 @@ public static EvaluateByBucketingResult evaluateByBucketing( if (matches) { Evaluation evaluation = new Evaluation(type, featureKey, variableKey) .reason(Evaluation.REASON_VARIABLE_OVERRIDE_RULE) + .variableKey(variableKey) .bucketKey(bucketKey) .bucketValue(bucketValue) .ruleKey(matchedTraffic.getKey()) @@ -333,6 +291,7 @@ public static EvaluateByBucketingResult evaluateByBucketing( Evaluation evaluation = new Evaluation(type, featureKey, variableKey) .reason(Evaluation.REASON_RULE) + .variableKey(variableKey) .bucketKey(bucketKey) .bucketValue(bucketValue) .ruleKey(matchedTraffic.getKey()) @@ -384,6 +343,7 @@ public static EvaluateByBucketingResult evaluateByBucketing( if (matches) { Evaluation evaluation = new Evaluation(type, featureKey, variableKey) .reason(Evaluation.REASON_VARIABLE_OVERRIDE_VARIATION) + .variableKey(variableKey) .bucketKey(bucketKey) .bucketValue(bucketValue) .ruleKey(matchedTraffic != null ? matchedTraffic.getKey() : null) @@ -413,6 +373,7 @@ public static EvaluateByBucketingResult evaluateByBucketing( Evaluation evaluation = new Evaluation(type, featureKey, variableKey) .reason(Evaluation.REASON_ALLOCATED) + .variableKey(variableKey) .bucketKey(bucketKey) .bucketValue(bucketValue) .ruleKey(matchedTraffic != null ? matchedTraffic.getKey() : null) @@ -436,6 +397,7 @@ public static EvaluateByBucketingResult evaluateByBucketing( if (variableSchema != null) { Evaluation evaluation = new Evaluation(type, featureKey, variableKey) .reason(Evaluation.REASON_VARIABLE_DEFAULT) + .variableKey(variableKey) .bucketKey(bucketKey) .bucketValue(bucketValue) .ruleKey(matchedTraffic.getKey()) @@ -459,6 +421,7 @@ public static EvaluateByBucketingResult evaluateByBucketing( // variable not found Evaluation evaluation = new Evaluation(type, featureKey, variableKey) .reason(Evaluation.REASON_VARIABLE_NOT_FOUND) + .variableKey(variableKey) .bucketKey(bucketKey) .bucketValue(bucketValue) .ruleKey(matchedTraffic != null ? matchedTraffic.getKey() : null) @@ -496,6 +459,7 @@ public static EvaluateByBucketingResult evaluateByBucketing( // Nothing matched Evaluation evaluation = new Evaluation(type, featureKey, variableKey) .reason(Evaluation.REASON_NO_MATCH) + .variableKey(variableKey) .bucketKey(bucketKey) .bucketValue(bucketValue) .enabled(false); diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateForced.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateForced.java index 83f8a72..37f6b6d 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateForced.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateForced.java @@ -58,7 +58,7 @@ public static EvaluateForcedResult evaluate(EvaluateOptions options, Feature fea DiagnosticReporter diagnostics = options.getDiagnostics(); InstanceEvaluationDataProvider evaluationData = options.getInstanceEvaluationDataProvider(); - InstanceEvaluationDataProvider.ForceResult forceResult = evaluationData.getMatchedForce(feature, context); + InstanceEvaluationDataProvider.ForceResult forceResult = evaluationData.getMatchedForce(feature, options); Force force = forceResult.getForce(); Integer forceIndex = forceResult.getForceIndex(); @@ -149,17 +149,7 @@ else if (force.getVariation() != null && feature.getVariations() != null) { // Check each override to see if segments match for (VariableOverride override : overrides) { - boolean matches = false; - - // Check conditions - if (override.getConditions() != null) { - matches = evaluationData.allConditionsAreMatched(override.getConditions(), context); - } - // Check segments - else if (override.getSegments() != null) { - Object parsedSegments = evaluationData.parseSegmentsIfStringified(override.getSegments()); - matches = evaluationData.allSegmentsAreMatched(parsedSegments, context); - } + boolean matches = Evaluate.variableOverrideIsMatched(override, options); if (matches) { variableValue = override.getValue(); @@ -194,15 +184,6 @@ else if (override.getSegments() != null) { return result; } - /** - * Legacy method for backward compatibility - * @deprecated Use evaluate() instead - */ - @Deprecated - public static EvaluateForcedResult evaluateForced(EvaluateOptions options, Feature feature, VariableSchema variableSchema) { - return evaluate(options, feature, variableSchema); - } - /** * Converts a Force object to a Map for storage in Evaluation * diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateOptions.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateOptions.java index 15b4857..7c5e6e8 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateOptions.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateOptions.java @@ -19,6 +19,7 @@ public class EvaluateOptions { private DiagnosticReporter diagnostics; private ModulesManager modulesManager; private InstanceEvaluationDataProvider evaluationData; + private java.util.function.Supplier dataSource; // Override options private Map stickyFeatures; @@ -26,6 +27,23 @@ public class EvaluateOptions { private String defaultVariationValue; private Object defaultVariableValue; private boolean defaultVariableValueSet; + private EvaluationSession session; + private DependencyOf dependencyOf; + private String dependencyPurpose; + interface StickySource { StickyState read(); } + static final class StickyState { + final Map features; + final Map variables; + final String generation; + StickyState(Map features, Map variables, String generation) { + this.features = features; + this.variables = variables; + this.generation = generation; + } + } + private StickySource stickySource; + private boolean inheritedStickyFeatures; + private boolean inheritedStickyVariables; // Constructors public EvaluateOptions() {} @@ -55,6 +73,17 @@ public EvaluateOptions(String type, String featureKey, String variableKey) { public String getDefaultVariationValue() { return defaultVariationValue; } public Object getDefaultVariableValue() { return defaultVariableValue; } public boolean hasDefaultVariableValue() { return defaultVariableValueSet; } + EvaluationSession getSession() { + if (session == null) session = new EvaluationSession(); + return session; + } + public DependencyOf getDependencyOf() { return dependencyOf; } + public String getDependencyPurpose() { return dependencyPurpose; } + public void setDependencyPurpose(String value) { dependencyPurpose = value; } + StickySource getStickySource() { return stickySource; } + String getSourceGeneration() { return stickySource == null ? null : stickySource.read().generation; } + boolean inheritsStickyFeatures() { return inheritedStickyFeatures; } + boolean inheritsStickyVariables() { return inheritedStickyVariables; } // Setters public void setType(String type) { this.type = type; } @@ -65,6 +94,9 @@ public EvaluateOptions(String type, String featureKey, String variableKey) { void setDiagnostics(DiagnosticReporter diagnostics) { this.diagnostics = diagnostics; } void setModulesManager(ModulesManager modulesManager) { this.modulesManager = modulesManager; } void setInstanceEvaluationDataProvider(InstanceEvaluationDataProvider evaluationData) { this.evaluationData = evaluationData; } + void setDataSource(java.util.function.Supplier source) { dataSource = source; } + void refreshDatafile() { if (dataSource != null) evaluationData = dataSource.get(); } + InstanceEvaluationDataProvider getCurrentDatafile() { return dataSource == null ? evaluationData : dataSource.get(); } public void setStickyFeatures(Map value) { this.stickyFeatures = value; } public void setStickyVariables(Map value) { this.stickyVariables = value; } public void setDefaultVariationValue(String defaultVariationValue) { this.defaultVariationValue = defaultVariationValue; } @@ -72,6 +104,22 @@ public void setDefaultVariableValue(Object defaultVariableValue) { this.defaultVariableValue = defaultVariableValue; this.defaultVariableValueSet = true; } + void setSession(EvaluationSession session) { this.session = session; } + public void setDependencyOf(DependencyOf dependencyOf) { this.dependencyOf = dependencyOf; } + void setStickySource(StickySource source, boolean features, boolean variables) { + stickySource = source; + inheritedStickyFeatures = features; + inheritedStickyVariables = variables; + } + void setInheritedStickyFeatures(boolean value) { inheritedStickyFeatures = value; } + void setInheritedStickyVariables(boolean value) { inheritedStickyVariables = value; } + void clearRequestSource() { stickySource = null; dataSource = null; } + void refreshSticky() { + if (stickySource == null) return; + StickyState state = stickySource.read(); + if (inheritedStickyFeatures) stickyFeatures = state.features; + if (inheritedStickyVariables) stickyVariables = state.variables; + } // Builder pattern methods public EvaluateOptions type(String type) { @@ -112,6 +160,7 @@ EvaluateOptions evaluationData(InstanceEvaluationDataProvider evaluationData) { public EvaluateOptions stickyFeatures(Map value) { this.stickyFeatures = value; return this; } public EvaluateOptions stickyVariables(Map value) { this.stickyVariables = value; return this; } public EvaluateOptions globalVariable(boolean value) { this.globalVariable = value; return this; } + public EvaluateOptions dependencyOf(DependencyOf value) { this.dependencyOf = value; return this; } public EvaluateOptions defaultVariationValue(String defaultVariationValue) { this.defaultVariationValue = defaultVariationValue; @@ -143,12 +192,19 @@ public EvaluateOptions copy() { copy.diagnostics = this.diagnostics; copy.modulesManager = this.modulesManager; copy.evaluationData = this.evaluationData; + copy.dataSource = this.dataSource; copy.stickyFeatures = this.stickyFeatures; copy.stickyVariables = this.stickyVariables; copy.globalVariable = this.globalVariable; copy.defaultVariationValue = this.defaultVariationValue; copy.defaultVariableValue = this.defaultVariableValue; copy.defaultVariableValueSet = this.defaultVariableValueSet; + copy.session = this.session; + copy.dependencyOf = this.dependencyOf; + copy.dependencyPurpose = this.dependencyPurpose; + copy.stickySource = this.stickySource; + copy.inheritedStickyFeatures = this.inheritedStickyFeatures; + copy.inheritedStickyVariables = this.inheritedStickyVariables; return copy; } diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateSticky.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateSticky.java index 96def47..6e1f64d 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateSticky.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateSticky.java @@ -35,7 +35,7 @@ public static Evaluation evaluateSticky(EvaluateOptions options) { .type(type) .featureKey(featureKey) .reason(Evaluation.REASON_STICKY) - .sticky(stickyMap) + .stickyFeature(stickyMap) .enabled((Boolean) stickyMap.get("enabled")); diagnostics.debug("using sticky enabled", null); @@ -70,19 +70,16 @@ public static Evaluation evaluateSticky(EvaluateOptions options) { if (variables.containsKey(variableKey)) { Object result = variables.get(variableKey); + evaluation = new Evaluation() + .type(type) + .featureKey(featureKey) + .reason(Evaluation.REASON_STICKY) + .variableKey(variableKey) + .variableValue(result); - if (result != null) { - evaluation = new Evaluation() - .type(type) - .featureKey(featureKey) - .reason(Evaluation.REASON_STICKY) - .variableKey(variableKey) - .variableValue(result); + diagnostics.debug("using sticky variable", null); - diagnostics.debug("using sticky variable", null); - - return evaluation; - } + return evaluation; } } } diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Evaluation.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Evaluation.java index 54aad70..b85ffd8 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Evaluation.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Evaluation.java @@ -20,16 +20,13 @@ public class Evaluation { // Evaluation reasons public static final String REASON_FEATURE_NOT_FOUND = "feature_not_found"; public static final String REASON_DISABLED = "disabled"; - public static final String REASON_REQUIRED = "required"; - public static final String REASON_REQUIRED_FEATURES_UNMET = "required_features_unmet"; - public static final String REASON_OUT_OF_RANGE = "out_of_range"; + public static final String REASON_CONDITIONS_UNMET = "conditions_unmet"; + public static final String REASON_VARIABLE_COMPOSED = "variable_composed"; public static final String REASON_NO_VARIATIONS = "no_variations"; public static final String REASON_VARIATION_DISABLED = "variation_disabled"; public static final String REASON_VARIABLE_NOT_FOUND = "variable_not_found"; public static final String REASON_VARIABLE_DEFAULT = "variable_default"; public static final String REASON_VARIABLE_DISABLED = "variable_disabled"; - @Deprecated - public static final String REASON_VARIABLE_OVERRIDE = "variable_override"; public static final String REASON_VARIABLE_OVERRIDE_RULE = "variable_override_rule"; public static final String REASON_VARIABLE_OVERRIDE_VARIATION = "variable_override_variation"; public static final String REASON_NO_MATCH = "no_match"; @@ -53,9 +50,8 @@ public class Evaluation { private Map traffic; private Integer forceIndex; private Map force; - private List> required; - private List requiredFeatures; - private Map sticky; + private Object conditions; + private Map stickyFeature; // Variation fields private Variation variation; @@ -70,6 +66,7 @@ public class Evaluation { private Integer variableOverrideIndex; private String variableOverrideKey; private List variableOverridePath; + private List> variableOverridePaths; // Required feature fields private String requiredFeatureKey; @@ -97,9 +94,8 @@ public Evaluation(String type, String featureKey, String reason) { public Map getTraffic() { return traffic; } public Integer getForceIndex() { return forceIndex; } public Map getForce() { return force; } - public List> getRequired() { return required; } - public List getRequiredFeatures() { return requiredFeatures; } - public Map getSticky() { return sticky; } + public Object getConditions() { return conditions; } + public Map getStickyFeature() { return stickyFeature; } public Variation getVariation() { return variation; } public String getVariationValue() { return variationValue; } public String getVariableKey() { return variableKey; } @@ -110,6 +106,7 @@ public Evaluation(String type, String featureKey, String reason) { public Integer getVariableOverrideIndex() { return variableOverrideIndex; } public String getVariableOverrideKey() { return variableOverrideKey; } public List getVariableOverridePath() { return variableOverridePath; } + public List> getVariableOverridePaths() { return variableOverridePaths; } public String getRequiredFeatureKey() { return requiredFeatureKey; } public String getRequiredVariation() { return requiredVariation; } public String getActualVariation() { return actualVariation; } @@ -126,9 +123,7 @@ public Evaluation(String type, String featureKey, String reason) { public void setTraffic(Map traffic) { this.traffic = traffic; } public void setForceIndex(Integer forceIndex) { this.forceIndex = forceIndex; } public void setForce(Map force) { this.force = force; } - public void setRequired(List> required) { this.required = required; } - public void setRequiredFeatures(List value) { this.requiredFeatures = value; } - public void setSticky(Map sticky) { this.sticky = sticky; } + public void setStickyFeature(Map value) { this.stickyFeature = value; } public void setVariation(Variation variation) { this.variation = variation; } public void setVariationValue(String variationValue) { this.variationValue = variationValue; } public void setVariableKey(String variableKey) { this.variableKey = variableKey; } @@ -138,6 +133,7 @@ public Evaluation(String type, String featureKey, String reason) { public void setVariableOverrideIndex(Integer variableOverrideIndex) { this.variableOverrideIndex = variableOverrideIndex; } public void setVariableOverrideKey(String value) { this.variableOverrideKey = value; } public void setVariableOverridePath(List value) { this.variableOverridePath = value; } + public void setVariableOverridePaths(List> value) { this.variableOverridePaths = value; } public void setRequiredFeatureKey(String requiredFeatureKey) { this.requiredFeatureKey = requiredFeatureKey; } public void setRequiredVariation(String requiredVariation) { this.requiredVariation = requiredVariation; } public void setActualVariation(String actualVariation) { this.actualVariation = actualVariation; } @@ -198,21 +194,9 @@ public Evaluation force(Map force) { return this; } - public Evaluation required(List> required) { - this.required = required; - return this; - } - public Evaluation requiredFeatures(List value) { this.requiredFeatures = value; return this; } - - public Evaluation sticky(Map sticky) { - this.sticky = sticky; - return this; - } - - public Evaluation variation(Variation variation) { - this.variation = variation; - return this; - } + public Evaluation variation(Variation value) { variation = value; return this; } + public Evaluation stickyFeature(Map value) { stickyFeature = value; return this; } + public Evaluation conditions(Object value) { this.conditions = value; return this; } public Evaluation variationValue(String variationValue) { this.variationValue = variationValue; @@ -242,6 +226,7 @@ public Evaluation variableOverrideIndex(Integer variableOverrideIndex) { } public Evaluation variableOverrideKey(String value) { this.variableOverrideKey = value; return this; } public Evaluation variableOverridePath(List value) { this.variableOverridePath = value; return this; } + public Evaluation variableOverridePaths(List> value) { this.variableOverridePaths = value; return this; } public Evaluation requiredFeatureKey(String requiredFeatureKey) { this.requiredFeatureKey = requiredFeatureKey; @@ -272,9 +257,8 @@ public Evaluation copy() { copy.traffic = this.traffic; copy.forceIndex = this.forceIndex; copy.force = this.force; - copy.required = this.required; - copy.requiredFeatures = this.requiredFeatures; - copy.sticky = this.sticky; + copy.conditions = this.conditions; + copy.stickyFeature = this.stickyFeature; copy.variation = this.variation; copy.variationValue = this.variationValue; copy.variableKey = this.variableKey; @@ -285,6 +269,7 @@ public Evaluation copy() { copy.variableOverrideIndex = this.variableOverrideIndex; copy.variableOverrideKey = this.variableOverrideKey; copy.variableOverridePath = this.variableOverridePath; + copy.variableOverridePaths = this.variableOverridePaths; copy.requiredFeatureKey = this.requiredFeatureKey; copy.requiredVariation = this.requiredVariation; copy.actualVariation = this.actualVariation; @@ -305,8 +290,7 @@ public String toString() { ", traffic=" + traffic + ", forceIndex=" + forceIndex + ", force=" + force + - ", required=" + required + - ", sticky=" + sticky + + ", stickyFeature=" + stickyFeature + ", variation=" + variation + ", variationValue=" + variationValue + ", variableKey='" + variableKey + '\'' + diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluationSession.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluationSession.java new file mode 100644 index 0000000..65c58ba --- /dev/null +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluationSession.java @@ -0,0 +1,328 @@ +package com.featurevisor.sdk; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.lang.reflect.Array; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.function.Supplier; + +/** Request local active chain and completed prerequisite getter values. */ +final class EvaluationSession { + static final class Cycle extends IllegalStateException { + Cycle() { super("Circular dependency"); } + } + + static final class RequirementFailure extends IllegalStateException { + RequirementFailure(RuntimeException cause) { super(cause.getMessage(), cause); } + } + + private static final ObjectMapper JSON = new ObjectMapper(); + private static final Object UNSUPPORTED = new Object(); + private static final int MAX_SNAPSHOT_DEPTH = 64; + private static final int MAX_SNAPSHOT_NODES = 4096; + + /** Memoisation is optional; host graphs must not cause unbounded traversal. */ + private static final class Traversal { + private final IdentityHashMap ancestors = new IdentityHashMap<>(); + private int remaining = MAX_SNAPSHOT_NODES; + + boolean visit(int depth) { return depth <= MAX_SNAPSHOT_DEPTH && remaining-- > 0; } + boolean enter(Object value) { return ancestors.put(value, Boolean.TRUE) == null; } + void leave(Object value) { ancestors.remove(value); } + } + + private static final class Frame { + private final DependencyOf requested; + private DependencyOf current; + private Frame(DependencyOf identity) { requested = identity; current = identity; } + } + + private static final class MemoKey { + private final InstanceEvaluationDataProvider data; + private final String snapshot; + private final Map stickyFeatures; + private final Map stickyVariables; + MemoKey(InstanceEvaluationDataProvider data, String snapshot, + Map stickyFeatures, Map stickyVariables) { + this.data = data; + this.snapshot = snapshot; + this.stickyFeatures = stickyFeatures; + this.stickyVariables = stickyVariables; + } + @Override public boolean equals(Object other) { + if (!(other instanceof MemoKey)) return false; + MemoKey value = (MemoKey) other; + return data == value.data && stickyFeatures == value.stickyFeatures + && stickyVariables == value.stickyVariables && snapshot.equals(value.snapshot); + } + @Override public int hashCode() { + int hash = 31 * System.identityHashCode(data) + snapshot.hashCode(); + return 31 * (31 * hash + System.identityHashCode(stickyFeatures)) + + System.identityHashCode(stickyVariables); + } + } + + private DependencyOf rootRequested; + private DependencyOf rootCurrent; + private List frames; + private Map memo; + private java.util.Set segments; + private EvaluateOptions rootOptions; + private List callbackOptions; + private boolean cycleDetected; + private RequirementFailure requirementFailure; + + boolean beginRoot(DependencyOf identity) { + if (rootRequested != null) return false; + rootRequested = rootCurrent = identity; + return true; + } + + void endRoot() { + if (rootOptions != null) rootOptions.clearRequestSource(); + if (callbackOptions != null) { + for (EvaluateOptions options : callbackOptions) options.clearRequestSource(); + } + rootOptions = null; + callbackOptions = null; + rootRequested = rootCurrent = null; + frames = null; + memo = null; + segments = null; + cycleDetected = false; + requirementFailure = null; + } + + void retainCallbackOptions(EvaluateOptions options) { + if (rootOptions == null) rootOptions = options; + else if (rootOptions != options) { + if (callbackOptions == null) callbackOptions = new ArrayList<>(); + callbackOptions.add(options); + } + } + + DependencyOf current() { return frames == null ? rootCurrent : frames.get(frames.size() - 1).current; } + + void updateCurrent(DependencyOf identity) { + if (frames == null) rootCurrent = identity; + else frames.get(frames.size() - 1).current = identity; + } + + private void prepareFrames() { + if (frames != null) return; + frames = new ArrayList<>(); + if (rootRequested != null) { + Frame root = new Frame(rootRequested); + root.current = rootCurrent; + frames.add(root); + } + } + + boolean hasDependencyStorage() { return frames != null || memo != null || segments != null; } + + void recordRequirementFailure(RequirementFailure error) { requirementFailure = error; } + + void throwIfFatal() { + if (cycleDetected) throw new Cycle(); + if (requirementFailure != null) throw requirementFailure; + } + + T segment(String key, Supplier evaluate) { + prepareFrames(); + if (segments == null) segments = new java.util.HashSet<>(); + if (segments.contains(key) || segments.size() + frames.size() - 1 >= 256) { + cycleDetected = true; + throw new Cycle(); + } + segments.add(key); + try { return evaluate.get(); } + finally { segments.remove(key); } + } + + Object read(EvaluateOptions requested, Supplier evaluate) { + prepareFrames(); + if (memo == null) memo = new HashMap<>(); + if (frames.size() + (segments == null ? 0 : segments.size()) > 256) { cycleDetected = true; throw new Cycle(); } + boolean borrowed = "composition".equals(requested.getDependencyPurpose()); + DependencyOf identity = DependencyOf.from(requested); + for (Frame frame : frames) { + if (frame.requested.equals(identity) || frame.current.equals(identity)) { + cycleDetected = true; + throw new Cycle(); + } + } + DependencyOf parent = current(); + MemoKey key = key(requested, parent); + if (key != null && memo.containsKey(key)) { + Object cached = borrowed ? memo.get(key) : copy(memo.get(key)); + if (cached != UNSUPPORTED) return cached; + memo.remove(key); + } + frames.add(new Frame(identity)); + try { + Object result = evaluate.get(); + throwIfFatal(); + if (key != null) { + Object safe = borrowed ? result : copy(result); + if (safe != UNSUPPORTED && key.equals(key(requested, parent))) memo.put(key, safe); + } + return result; + } finally { + frames.remove(frames.size() - 1); + } + } + + private static MemoKey key(EvaluateOptions options, DependencyOf parent) { + try { + StringBuilder out = new StringBuilder(); + Traversal traversal = new Traversal(); + DependencyOf identity = DependencyOf.from(options); + Object[] parts = { + identity.getType(), identity.getFeatureKey(), identity.getVariableKey(), options.getDependencyPurpose(), + parent == null ? null : parent.getType(), + parent == null ? null : parent.getFeatureKey(), + parent == null ? null : parent.getVariableKey(), + options.getContext(), + options.getSourceGeneration() + }; + for (Object part : parts) if (!append(out, part, traversal, 0)) return null; + List moduleIds = new ArrayList<>(); + if (options.getModulesManager() != null) { + for (FeaturevisorModule module : options.getModulesManager().phaseModules()) moduleIds.add(module.getId()); + } + if (!append(out, moduleIds, traversal, 0) + || !append(out, options.getModulesManager() == null ? 0 : options.getModulesManager().getRevision(), traversal, 0)) return null; + // Sticky maps and nested values are read only inputs. Identity plus + // the owner's generation detects replacements without walking them. + return new MemoKey(options.getInstanceEvaluationDataProvider(), out.toString(), + options.getStickyFeatures(), options.getStickyVariables()); + } catch (RuntimeException error) { + // A host collection may reject traversal or change while being inspected. + return null; + } + } + + private static boolean append(StringBuilder out, Object value, Traversal traversal, int depth) { + StringBuilder part = new StringBuilder(); + if (!encode(part, value, traversal, depth)) return false; + out.append(part.length()).append(':').append(part); + return true; + } + + private static boolean immutableNumber(Object value) { + Class type = value.getClass(); + if (type == Double.class) return Double.isFinite((Double) value); + if (type == Float.class) return Float.isFinite((Float) value); + return type == Byte.class || type == Short.class || type == Integer.class || type == Long.class + || type == BigInteger.class || type == BigDecimal.class; + } + + private static boolean encode(StringBuilder out, Object value, Traversal traversal, int depth) { + if (!traversal.visit(depth)) return false; + if (value == null) { out.append('n'); return true; } + if (value instanceof String) { + try { out.append('s').append(JSON.writeValueAsString(value)); return true; } + catch (JsonProcessingException error) { return false; } + } + if (value instanceof Boolean) { out.append((Boolean) value ? 't' : 'f'); return true; } + if (value instanceof Number) { + if (!immutableNumber(value)) return false; + // Preserve class, floating signed zero, and BigDecimal scale for hooks. + out.append('d').append(value.getClass().getName()).append(':').append(value.toString()); + return true; + } + if (value.getClass() == Date.class) { + out.append('D').append(((Date) value).getTime()); + return true; + } + if (!(value instanceof List) && !(value instanceof Map) && !value.getClass().isArray()) return false; + if (!traversal.enter(value)) return false; + try { + out.append(value.getClass().getName()).append(':'); + if (value instanceof List) { + List values = (List) value; + if (values.size() > traversal.remaining) return false; + out.append('['); + for (Object item : values) if (!append(out, item, traversal, depth + 1)) return false; + out.append(']'); + return true; + } + if (value.getClass().isArray()) { + int length = Array.getLength(value); + if (length > traversal.remaining) return false; + out.append('a'); + for (int index = 0; index < length; index++) { + if (!append(out, Array.get(value, index), traversal, depth + 1)) return false; + } + return true; + } + Map values = (Map) value; + if (values.size() > traversal.remaining / 2) return false; + TreeMap sorted = new TreeMap<>(); + for (Map.Entry entry : values.entrySet()) { + if (!(entry.getKey() instanceof String)) return false; + sorted.put((String) entry.getKey(), entry.getValue()); + } + out.append('{'); + for (Map.Entry entry : sorted.entrySet()) { + if (!append(out, entry.getKey(), traversal, depth + 1) + || !append(out, entry.getValue(), traversal, depth + 1)) return false; + } + out.append('}'); + return true; + } finally { + traversal.leave(value); + } + } + + private static Object copy(Object value) { + try { + return copy(value, new Traversal(), 0); + } catch (RuntimeException error) { + return UNSUPPORTED; + } + } + + private static Object copy(Object value, Traversal traversal, int depth) { + if (!traversal.visit(depth)) return UNSUPPORTED; + if (value == null || value instanceof String || value instanceof Boolean) return value; + if (value instanceof Number) return immutableNumber(value) ? value : UNSUPPORTED; + if (value.getClass() == Date.class) return new Date(((Date) value).getTime()); + if (!(value instanceof List) && !(value instanceof Map)) return UNSUPPORTED; + if (!traversal.enter(value)) return UNSUPPORTED; + try { + if (value instanceof List) { + List values = (List) value; + if (values.size() > traversal.remaining) return UNSUPPORTED; + List result = new ArrayList<>(); + for (Object item : values) { + Object copied = copy(item, traversal, depth + 1); + if (copied == UNSUPPORTED) return UNSUPPORTED; + result.add(copied); + } + return result; + } + Map values = (Map) value; + if (values.size() > traversal.remaining / 2) return UNSUPPORTED; + Map result = new HashMap<>(); + for (Map.Entry entry : values.entrySet()) { + if (!(entry.getKey() instanceof String)) return UNSUPPORTED; + if (!traversal.visit(depth + 1)) return UNSUPPORTED; + Object copied = copy(entry.getValue(), traversal, depth + 1); + if (copied == UNSUPPORTED) return UNSUPPORTED; + result.put((String) entry.getKey(), copied); + } + return result; + } finally { + traversal.leave(value); + } + } +} diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Events.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Events.java index f8ab141..bcff853 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Events.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Events.java @@ -20,75 +20,14 @@ final class Events { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private static String fingerprint(Object value, String hash) { - if (hash != null) return hash; + private static String fingerprint(Object value) { try { return OBJECT_MAPPER.writeValueAsString(value); } catch (Exception ignored) { return String.valueOf(value); } } - private static void collectSegmentKeys(Object value, Set result) { - if (value == null) return; - if (value instanceof String) { - String string = (String) value; - if ("*".equals(string)) return; - if (string.startsWith("{") || string.startsWith("[")) { - try { collectSegmentKeys(OBJECT_MAPPER.readValue(string, Object.class), result); return; } - catch (Exception ignored) { } - } - result.add(string); - } else if (value instanceof List) { - for (Object item : (List) value) collectSegmentKeys(item, result); - } else if (value instanceof Map) { - for (Object item : ((Map) value).values()) collectSegmentKeys(item, result); - } - } - - private static void collectRequiredFeatureKeys(List requirements, Set result) { - if (requirements == null) return; - for (Object item : requirements) { - if (item instanceof String) result.add((String) item); - else if (item instanceof Map) { - Object key = ((Map) item).get("feature"); - if (key == null) key = ((Map) item).get("key"); - if (key instanceof String) result.add((String) key); - } - } - } - - private static void collectOverrides(Map> groups, Set segments, Set features) { - if (groups == null) return; - for (List overrides : groups.values()) { - if (overrides == null) continue; - for (VariableOverride override : overrides) { - collectSegmentKeys(override.getSegments(), segments); - collectRequiredFeatureKeys(override.getRequiredFeatures(), features); - } - } - } - - private static List> featureDependencies(Feature feature) { - Set segments = new HashSet<>(), features = new HashSet<>(); - List required = feature.getRequiredFeatures() != null ? feature.getRequiredFeatures() : feature.getRequired(); - collectRequiredFeatureKeys(required, features); - if (feature.getTraffic() != null) for (Traffic traffic : feature.getTraffic()) { - collectSegmentKeys(traffic.getSegments(), segments); - collectOverrides(traffic.getVariableOverrides(), segments, features); - } - if (feature.getForce() != null) for (Force force : feature.getForce()) collectSegmentKeys(force.getSegments(), segments); - if (feature.getVariations() != null) for (Variation variation : feature.getVariations()) { - collectOverrides(variation.getVariableOverrides(), segments, features); - } - return Arrays.asList(segments, features); - } - - private static List> variableDependencies(GlobalVariable variable) { - Set segments = new HashSet<>(), features = new HashSet<>(); - collectRequiredFeatureKeys(variable.getRequiredFeatures(), features); - if (variable.getOverrides() != null) for (VariableOverride override : variable.getOverrides()) { - collectSegmentKeys(override.getSegments(), segments); - collectRequiredFeatureKeys(override.getRequiredFeatures(), features); - } - return Arrays.asList(segments, features); + private static boolean hashChanged(String before, String after) { + return before == null || before.isEmpty() || after == null || after.isEmpty() + || !before.equals(after); } /** @@ -150,6 +89,17 @@ public static FeaturevisorEventDetails getParamsForDatafileSetEvent( DatafileContent previousDatafileContent, DatafileContent newDatafileContent, boolean replace) { + return getParamsForDatafileSetEvent(previousDatafileContent, newDatafileContent, replace, null, null); + } + + static FeaturevisorEventDetails getParamsForProviderSetEvent( + InstanceEvaluationDataProvider previous, InstanceEvaluationDataProvider current, boolean replace) { + return getParamsForDatafileSetEvent(previous.getDatafile(), current.getDatafile(), replace, previous, current); + } + + private static FeaturevisorEventDetails getParamsForDatafileSetEvent( + DatafileContent previousDatafileContent, DatafileContent newDatafileContent, boolean replace, + InstanceEvaluationDataProvider previousProvider, InstanceEvaluationDataProvider currentProvider) { if (previousDatafileContent == null) { previousDatafileContent = new DatafileContent(); @@ -158,6 +108,20 @@ public static FeaturevisorEventDetails getParamsForDatafileSetEvent( newDatafileContent = new DatafileContent(); } + if ((previousDatafileContent.getFeatures() == null || previousDatafileContent.getFeatures().isEmpty()) + && (previousDatafileContent.getVariables() == null || previousDatafileContent.getVariables().isEmpty()) + && (previousDatafileContent.getSegments() == null || previousDatafileContent.getSegments().isEmpty())) { + // Every incoming entity is already announced, so no dependency traversal is needed. + FeaturevisorEventDetails details = new FeaturevisorEventDetails(); + details.put("revision", newDatafileContent.getRevision()); + details.put("previousRevision", previousDatafileContent.getRevision()); + details.put("revisionChanged", !Objects.equals(previousDatafileContent.getRevision(), newDatafileContent.getRevision())); + details.put("features", newDatafileContent.getFeatures() == null ? new ArrayList<>() : new ArrayList<>(newDatafileContent.getFeatures().keySet())); + details.put("variables", newDatafileContent.getVariables() == null ? new ArrayList<>() : new ArrayList<>(newDatafileContent.getVariables().keySet())); + details.put("replaced", replace); + return details; + } + String previousRevision = previousDatafileContent.getRevision(); List previousFeatureKeys = new ArrayList<>(); if (previousDatafileContent.getFeatures() != null) { @@ -176,28 +140,17 @@ public static FeaturevisorEventDetails getParamsForDatafileSetEvent( Set allFeatureKeys = new HashSet<>(previousFeatureKeys); allFeatureKeys.addAll(newFeatureKeys); for (String key : allFeatureKeys) { Feature before = previousFeatures.get(key), after = newFeatures.get(key); - if (before == null || after == null || !Objects.equals(fingerprint(before, before.getHash()), fingerprint(after, after.getHash()))) changedFeatures.add(key); + if (before == null || after == null || hashChanged(before.getHash(), after.getHash())) changedFeatures.add(key); } Map previousSegments = previousDatafileContent.getSegments() != null ? previousDatafileContent.getSegments() : java.util.Collections.emptyMap(); Map newSegments = newDatafileContent.getSegments() != null ? newDatafileContent.getSegments() : java.util.Collections.emptyMap(); Set changedSegments = new HashSet<>(); Set allSegmentKeys = new HashSet<>(previousSegments.keySet()); allSegmentKeys.addAll(newSegments.keySet()); - for (String key : allSegmentKeys) if (!Objects.equals(fingerprint(previousSegments.get(key), null), fingerprint(newSegments.get(key), null))) changedSegments.add(key); - - Map allFeatures = new HashMap<>(previousFeatures); allFeatures.putAll(newFeatures); - boolean updated; - do { - updated = false; - for (Map.Entry entry : allFeatures.entrySet()) { - if (changedFeatures.contains(entry.getKey())) continue; - List> dependencies = featureDependencies(entry.getValue()); - if (!java.util.Collections.disjoint(dependencies.get(0), changedSegments) - || !java.util.Collections.disjoint(dependencies.get(1), changedFeatures)) { - changedFeatures.add(entry.getKey()); updated = true; - } - } - } while (updated); + for (String key : allSegmentKeys) { + Segment before = previousSegments.get(key), after = newSegments.get(key); + if (before != after && !Objects.equals(fingerprint(before), fingerprint(after))) changedSegments.add(key); + } FeaturevisorEventDetails details = new FeaturevisorEventDetails(); details.put("revision", newRevision); @@ -207,16 +160,13 @@ public static FeaturevisorEventDetails getParamsForDatafileSetEvent( Map previousVariables = previousDatafileContent.getVariables() != null ? previousDatafileContent.getVariables() : java.util.Collections.emptyMap(); Map newVariables = newDatafileContent.getVariables() != null ? newDatafileContent.getVariables() : java.util.Collections.emptyMap(); for (String key : previousVariables.keySet()) { - if (!newVariables.containsKey(key) || !Objects.equals(fingerprint(previousVariables.get(key), previousVariables.get(key).getHash()), fingerprint(newVariables.get(key), newVariables.get(key).getHash()))) variableKeys.add(key); + if (!newVariables.containsKey(key) || hashChanged(previousVariables.get(key).getHash(), newVariables.get(key).getHash())) variableKeys.add(key); } for (String key : newVariables.keySet()) if (!previousVariables.containsKey(key)) variableKeys.add(key); - Map allVariables = new HashMap<>(previousVariables); allVariables.putAll(newVariables); - for (Map.Entry entry : allVariables.entrySet()) { - if (variableKeys.contains(entry.getKey())) continue; - List> dependencies = variableDependencies(entry.getValue()); - if (!java.util.Collections.disjoint(dependencies.get(0), changedSegments) - || !java.util.Collections.disjoint(dependencies.get(1), changedFeatures)) variableKeys.add(entry.getKey()); - } + Dependencies.addChanges(changedFeatures, variableKeys, changedSegments, + previousProvider == null ? Dependencies.create(previousDatafileContent) : previousProvider.getDependencyIndex()); + Dependencies.addChanges(changedFeatures, variableKeys, changedSegments, + currentProvider == null ? Dependencies.create(newDatafileContent) : currentProvider.deriveDependencyIndex(previousProvider)); details.put("features", new ArrayList<>(changedFeatures)); details.put("variables", new ArrayList<>(variableKeys)); details.put("replaced", replace); diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Feature.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Feature.java index dca7da3..e7c2416 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Feature.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Feature.java @@ -1,13 +1,26 @@ package com.featurevisor.sdk; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonSetter; -import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.annotation.JsonAnyGetter; import java.util.List; import java.util.Map; -import java.util.ArrayList; public class Feature { + @JsonIgnore private Object conditions; + private boolean conditionsPresent; + @JsonIgnore + public Object getConditions() { return conditions; } + public void setConditions(Object value) { conditions = value; conditionsPresent = value != null; } + boolean hasConditions() { return conditionsPresent; } + @JsonSetter("conditions") + private void readConditions(Object value) { conditions = value; conditionsPresent = true; } + @JsonAnyGetter + private Map conditionsProperty() { + return conditionsPresent ? java.util.Collections.singletonMap("conditions", conditions) : Map.of(); + } + @JsonProperty("key") private String key; @@ -17,13 +30,12 @@ public class Feature { @JsonProperty("deprecated") private Boolean deprecated; - @JsonProperty("required") - @JsonSetter(nulls = Nulls.AS_EMPTY) - private List required = new ArrayList<>(); // Can be String or RequiredWithVariation - @JsonProperty("requiredFeatures") private List requiredFeatures; + @JsonProperty("requiredVariables") + private Object requiredVariables; + @JsonProperty("variablesSchema") private Map variablesSchema; @@ -42,9 +54,6 @@ public class Feature { @JsonProperty("force") private List force; - @JsonProperty("ranges") - private List ranges; - // Constructors public Feature() { } @@ -78,19 +87,10 @@ public void setDeprecated(Boolean deprecated) { this.deprecated = deprecated; } - public List getRequired() { - if (this.required == null) { - this.required = new ArrayList<>(); - } - return this.required; - } - - public void setRequired(List required) { - this.required = (required != null) ? required : new ArrayList<>(); - } - public List getRequiredFeatures() { return requiredFeatures; } public void setRequiredFeatures(List requiredFeatures) { this.requiredFeatures = requiredFeatures; } + public Object getRequiredVariables() { return requiredVariables; } + public void setRequiredVariables(Object requiredVariables) { this.requiredVariables = requiredVariables; } public Map getVariablesSchema() { return variablesSchema; @@ -140,11 +140,4 @@ public void setForce(List force) { this.force = force; } - public List getRanges() { - return ranges; - } - - public void setRanges(List ranges) { - this.ranges = ranges; - } } diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/FeatureAssertion.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/FeatureAssertion.java index 6ac74c0..546d65e 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/FeatureAssertion.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/FeatureAssertion.java @@ -20,8 +20,11 @@ public class FeatureAssertion { @JsonProperty("at") private Integer at; // bucket weight: 0 to 100 - @JsonProperty("sticky") - private Map sticky; + @JsonProperty("stickyFeatures") + private Map stickyFeatures; + + @JsonProperty("stickyVariables") + private Map stickyVariables; @JsonProperty("context") private Map context; @@ -81,12 +84,20 @@ public void setAt(Integer at) { this.at = at; } - public Map getSticky() { - return sticky; + public Map getStickyFeatures() { + return stickyFeatures; + } + + public void setStickyFeatures(Map stickyFeatures) { + this.stickyFeatures = stickyFeatures; + } + + public Map getStickyVariables() { + return stickyVariables; } - public void setSticky(Map sticky) { - this.sticky = sticky; + public void setStickyVariables(Map stickyVariables) { + this.stickyVariables = stickyVariables; } public Map getContext() { diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/FeatureChildAssertion.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/FeatureChildAssertion.java index b397fbe..8b31c6f 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/FeatureChildAssertion.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/FeatureChildAssertion.java @@ -7,8 +7,11 @@ * Represents a feature child assertion */ public class FeatureChildAssertion { - @JsonProperty("sticky") - private Map sticky; + @JsonProperty("stickyFeatures") + private Map stickyFeatures; + + @JsonProperty("stickyVariables") + private Map stickyVariables; @JsonProperty("context") private Map context; @@ -33,12 +36,20 @@ public class FeatureChildAssertion { public FeatureChildAssertion() {} - public Map getSticky() { - return sticky; + public Map getStickyFeatures() { + return stickyFeatures; + } + + public void setStickyFeatures(Map stickyFeatures) { + this.stickyFeatures = stickyFeatures; + } + + public Map getStickyVariables() { + return stickyVariables; } - public void setSticky(Map sticky) { - this.sticky = sticky; + public void setStickyVariables(Map stickyVariables) { + this.stickyVariables = stickyVariables; } public Map getContext() { diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Featurevisor.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Featurevisor.java index 2c745d8..bca434d 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Featurevisor.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Featurevisor.java @@ -22,18 +22,20 @@ public class Featurevisor { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); // from options - private Map context = new HashMap<>(); + private final Object stateLock = new Object(); + private volatile Map context = new HashMap<>(); private DiagnosticReporter diagnostics; - private Map stickyFeatures; - private Map stickyVariables; + private volatile Map stickyFeatures; + private volatile Map stickyVariables; + private final java.util.concurrent.atomic.AtomicLong stateGeneration = new java.util.concurrent.atomic.AtomicLong(); private FeaturevisorDiagnosticHandler onDiagnostic; - private boolean closed = false; + private volatile boolean closed = false; // internally created - private InstanceEvaluationDataProvider evaluationData; + private volatile InstanceEvaluationDataProvider evaluationData; private ModulesManager modulesManager; private Emitter emitter; - private final List moduleDiagnosticSubscriptions = new ArrayList<>(); + private final List moduleDiagnosticSubscriptions = new java.util.concurrent.CopyOnWriteArrayList<>(); private static final DatafileContent emptyDatafile; @@ -149,6 +151,7 @@ private static class ModuleDiagnosticSubscription { public static class OverrideOptions { private Map stickyFeatures; private Map stickyVariables; + private EvaluateOptions.StickySource stickySource; private String defaultVariationValue; private Object defaultVariableValue; private boolean defaultVariableValueSet; @@ -158,6 +161,7 @@ public OverrideOptions() {} // Getters Map getInternalStickyFeatures() { return stickyFeatures; } Map getInternalStickyVariables() { return stickyVariables; } + EvaluateOptions.StickySource getInternalStickySource() { return stickySource; } public String getDefaultVariationValue() { return defaultVariationValue; } public Object getDefaultVariableValue() { return defaultVariableValue; } public boolean hasDefaultVariableValue() { return defaultVariableValueSet; } @@ -165,6 +169,7 @@ public OverrideOptions() {} // Setters void setInternalStickyFeatures(Map value) { this.stickyFeatures = value; } void setInternalStickyVariables(Map value) { this.stickyVariables = value; } + void setInternalStickySource(EvaluateOptions.StickySource value) { this.stickySource = value; } public void setDefaultVariationValue(String defaultVariationValue) { this.defaultVariationValue = defaultVariationValue; } public void setDefaultVariableValue(Object defaultVariableValue) { this.defaultVariableValue = defaultVariableValue; @@ -209,7 +214,12 @@ private Featurevisor(FeaturevisorOptions options) { this.diagnostics = DiagnosticReporter.createDiagnosticReporter(new DiagnosticReporter.DiagnosticReporterOptions() .level(options.getLogLevel() != null ? options.getLogLevel() : FeaturevisorLogLevel.INFO) .handler((level, message, details) -> { + if (!hasDiagnosticInterest(level)) { + return; + } Map normalizedDetails = details != null ? new HashMap<>(details) : new HashMap<>(); + Object explicitCode = normalizedDetails.remove("code"); + Object originalError = normalizedDetails.remove("originalError"); Object evaluationValue = normalizedDetails.get("evaluation"); if (evaluationValue instanceof Evaluation) { Evaluation evaluation = (Evaluation) evaluationValue; @@ -220,8 +230,6 @@ private Featurevisor(FeaturevisorOptions options) { normalizedDetails.put("evaluation", evaluation); } Object reason = normalizedDetails.get("reason"); - Object explicitCode = normalizedDetails.remove("code"); - Object originalError = normalizedDetails.remove("originalError"); String code = explicitCode != null ? explicitCode.toString() : (reason != null ? reason.toString() : message); if ("feature is deprecated".equals(message)) code = "deprecated_feature"; if ("variable is deprecated".equals(message)) code = "deprecated_variable"; @@ -317,6 +325,18 @@ private boolean shouldReport(FeaturevisorLogLevel diagnosticLevel, FeaturevisorL return getLogLevelIndex(diagnosticLevel) >= getLogLevelIndex(subscriptionLevel); } + private boolean hasDiagnosticInterest(FeaturevisorLogLevel level) { + if (FeaturevisorLogLevel.ERROR.equals(level)) { + return true; + } + for (ModuleDiagnosticSubscription subscription : moduleDiagnosticSubscriptions) { + if (shouldReport(level, subscription.logLevel)) { + return true; + } + } + return shouldReport(level, this.diagnostics.getLevel()); + } + private int getLogLevelIndex(FeaturevisorLogLevel level) { if (level == null) { level = FeaturevisorLogLevel.INFO; @@ -396,16 +416,23 @@ void reportDiagnostic(FeaturevisorDiagnostic diagnostic, FeaturevisorModule sour } /** - * Set datafile + * Set datafile, merging by default. + * @return true when accepted, including unchanged content, or false when rejected or closed */ - public void setDatafile(DatafileContent datafile) { - setDatafile(datafile, false); + public boolean setDatafile(DatafileContent datafile) { + return setDatafile(datafile, false); } - public void setDatafile(DatafileContent datafile, boolean replace) { + /** + * Set datafile, optionally replacing the stored content. + * @return true when accepted, including unchanged content, or false when rejected or closed + */ + public boolean setDatafile(DatafileContent datafile, boolean replace) { if (this.closed) { - return; + return false; } + FeaturevisorEventDetails details; + List> normalizationErrors = new ArrayList<>(); try { if (datafile == null) { throw new IllegalArgumentException("Datafile must be an object"); @@ -414,49 +441,67 @@ public void setDatafile(DatafileContent datafile, boolean replace) { datafile.getSegments() == null || datafile.getFeatures() == null) { throw new IllegalArgumentException("Invalid datafile"); } - DatafileContent nextDatafile = replace ? datafile : mergeDatafiles(this.evaluationData.getDatafile(), datafile); - InstanceEvaluationDataProvider newInstanceEvaluationDataProvider = new InstanceEvaluationDataProvider(new InstanceEvaluationDataProvider.InstanceEvaluationDataProviderOptions() - .datafile(nextDatafile) - .diagnostics(this.diagnostics)); - - FeaturevisorEventDetails details = Events.getParamsForDatafileSetEvent( - this.evaluationData.getDatafile(), newInstanceEvaluationDataProvider.getDatafile(), replace); - - this.evaluationData = newInstanceEvaluationDataProvider; - - reportDiagnostic(new FeaturevisorDiagnostic() - .level(FeaturevisorLogLevel.INFO) - .code("datafile_set") - .message("Datafile set") - .details(details), null); - this.emitter.trigger(FeaturevisorEventName.DATAFILE_SET, details); + DatafileContent incoming = Normalize.incoming(datafile, normalizationErrors); + synchronized (stateLock) { + if (closed) return false; + DatafileContent nextDatafile = replace ? incoming : mergeDatafiles(this.evaluationData.getDatafile(), incoming); + InstanceEvaluationDataProvider newInstanceEvaluationDataProvider = new InstanceEvaluationDataProvider(new InstanceEvaluationDataProvider.InstanceEvaluationDataProviderOptions() + .datafile(nextDatafile) + .diagnostics(this.diagnostics)); + + details = Events.getParamsForProviderSetEvent( + this.evaluationData, newInstanceEvaluationDataProvider, replace); + + if (!replace) newInstanceEvaluationDataProvider.retainCaches(this.evaluationData, incoming); + this.evaluationData = newInstanceEvaluationDataProvider; + stateGeneration.incrementAndGet(); + } } catch (Exception e) { reportDiagnostic(new FeaturevisorDiagnostic() .level(FeaturevisorLogLevel.ERROR) .code("invalid_datafile") .message("Could not parse datafile") .originalError(e), null); + return false; } + this.evaluationData.reportErrors(normalizationErrors); + reportDiagnostic(new FeaturevisorDiagnostic() + .level(FeaturevisorLogLevel.INFO) + .code("datafile_set") + .message("Datafile set") + .details(details), null); + this.emitter.trigger(FeaturevisorEventName.DATAFILE_SET, details); + return true; } /** - * Set datafile from string + * Set datafile from string, merging by default. + * @return true when accepted, including unchanged content, or false when rejected or closed */ - public void setDatafile(String datafileString) { - setDatafile(datafileString, false); + public boolean setDatafile(String datafileString) { + return setDatafile(datafileString, false); } - public void setDatafile(String datafileString, boolean replace) { + /** + * Set datafile from string, optionally replacing the stored content. + * @return true when accepted, including unchanged content, or false when rejected or closed + */ + public boolean setDatafile(String datafileString, boolean replace) { + if (this.closed) { + return false; + } + DatafileContent datafile; try { - DatafileContent datafile = DatafileContent.fromJson(datafileString); - setDatafile(datafile, replace); + datafile = DatafileContent.fromJson(datafileString); } catch (Exception e) { reportDiagnostic(new FeaturevisorDiagnostic() .level(FeaturevisorLogLevel.ERROR) .code("invalid_datafile") .message("Could not parse datafile") .originalError(e), null); + return false; } + return setDatafile(datafile, replace); } private DatafileContent mergeDatafiles(DatafileContent previous, DatafileContent incoming) { @@ -506,32 +551,34 @@ public void setStickyFeatures(Map sticky) { } public void setStickyFeatures(Map sticky, boolean replace) { - Map previousStickyFeatures = this.stickyFeatures != null ? new HashMap<>(this.stickyFeatures) : new HashMap<>(); - - if (replace) { - this.stickyFeatures = new HashMap<>(sticky); - } else { - this.stickyFeatures = new HashMap<>(this.stickyFeatures != null ? this.stickyFeatures : new HashMap<>()); - this.stickyFeatures.putAll(sticky); + FeaturevisorEventDetails params; + synchronized (stateLock) { + Map previous = this.stickyFeatures; + Map next = new HashMap<>(); + if (!replace && previous != null) next.putAll(previous); + next.putAll(sticky); + this.stickyFeatures = next; + stateGeneration.incrementAndGet(); + params = Events.getParamsForStickyFeaturesSetEvent(previous, next, replace); } - - FeaturevisorEventDetails params = Events.getParamsForStickyFeaturesSetEvent( - previousStickyFeatures, this.stickyFeatures, replace); - - reportDiagnostic(new FeaturevisorDiagnostic() - .level(FeaturevisorLogLevel.INFO) - .code("sticky_features_set") - .message("Sticky features set") - .details(params), null); + reportDiagnostic(new FeaturevisorDiagnostic().level(FeaturevisorLogLevel.INFO) + .code("sticky_features_set").message("Sticky features set").details(params), null); this.emitter.trigger(FeaturevisorEventName.STICKY_FEATURES_SET, params); } public void setStickyVariables(Map sticky, boolean replace) { - Map previous = this.stickyVariables != null ? new HashMap<>(this.stickyVariables) : new HashMap<>(); - this.stickyVariables = replace ? new HashMap<>(sticky) : new HashMap<>(previous); - if (!replace) { this.stickyVariables.putAll(sticky); } - FeaturevisorEventDetails params = Events.getParamsForStickyVariablesSetEvent(previous, this.stickyVariables, replace); - reportDiagnostic(new FeaturevisorDiagnostic().level(FeaturevisorLogLevel.INFO).code("sticky_variables_set").message("Sticky variables set").details(params), null); + FeaturevisorEventDetails params; + synchronized (stateLock) { + Map previous = this.stickyVariables; + Map next = new HashMap<>(); + if (!replace && previous != null) next.putAll(previous); + next.putAll(sticky); + this.stickyVariables = next; + stateGeneration.incrementAndGet(); + params = Events.getParamsForStickyVariablesSetEvent(previous, next, replace); + } + reportDiagnostic(new FeaturevisorDiagnostic().level(FeaturevisorLogLevel.INFO) + .code("sticky_variables_set").message("Sticky variables set").details(params), null); this.emitter.trigger(FeaturevisorEventName.STICKY_VARIABLES_SET, params); } public void setStickyVariables(Map sticky) { setStickyVariables(sticky, false); } @@ -593,10 +640,14 @@ public FeaturevisorUnsubscribe on(FeaturevisorEventName eventName, FeaturevisorE * Close instance */ public void close() { - this.closed = true; + synchronized (stateLock) { + if (this.closed) return; + this.closed = true; + } this.modulesManager.closeAll(); this.moduleDiagnosticSubscriptions.clear(); this.emitter.clearAll(); + this.evaluationData.clearCaches(); } /** @@ -607,16 +658,15 @@ public void setContext(Map context) { } public void setContext(Map context, boolean replace) { - if (replace) { - this.context = new HashMap<>(context); - } else { - this.context = new HashMap<>(this.context); - this.context.putAll(context); - } - FeaturevisorEventDetails eventDetails = new FeaturevisorEventDetails(); - eventDetails.put("context", this.context); - eventDetails.put("replaced", replace); + synchronized (stateLock) { + Map next = replace ? new HashMap<>() : new HashMap<>(this.context); + next.putAll(context); + this.context = next; + stateGeneration.incrementAndGet(); + eventDetails.put("context", next); + eventDetails.put("replaced", replace); + } this.emitter.trigger(FeaturevisorEventName.CONTEXT_SET, eventDetails); reportDiagnostic(new FeaturevisorDiagnostic() @@ -672,20 +722,38 @@ private EvaluateOptions getEvaluationDependencies(Map context, O options = new OverrideOptions(); } - Map mergedStickyFeatures = options.getInternalStickyFeatures() != null ? options.getInternalStickyFeatures() : this.stickyFeatures; - Map mergedStickyVariables = options.getInternalStickyVariables() != null ? options.getInternalStickyVariables() : this.stickyVariables; + synchronized (stateLock) { + Map mergedStickyFeatures = options.getInternalStickyFeatures() != null ? options.getInternalStickyFeatures() : this.stickyFeatures; + Map mergedStickyVariables = options.getInternalStickyVariables() != null ? options.getInternalStickyVariables() : this.stickyVariables; + + EvaluateOptions.StickySource source = options.getInternalStickySource() != null + ? options.getInternalStickySource() + : this::readStickyState; + EvaluateOptions result = new EvaluateOptions() + .context(getContext(context)) + .diagnostics(this.diagnostics) + .modulesManager(this.modulesManager) + .evaluationData(this.evaluationData) + .stickyFeatures(mergedStickyFeatures) + .stickyVariables(mergedStickyVariables) + .defaultVariationValue(options.getDefaultVariationValue()) + .defaultVariableValue(options.getDefaultVariableValue(), options.hasDefaultVariableValue()); + result.setDataSource(() -> this.evaluationData); + result.setStickySource(source, + options.getInternalStickySource() != null || options.getInternalStickyFeatures() == null, + options.getInternalStickySource() != null || options.getInternalStickyVariables() == null); + return result; + } + } - return new EvaluateOptions() - .context(getContext(context)) - .diagnostics(this.diagnostics) - .modulesManager(this.modulesManager) - .evaluationData(this.evaluationData) - .stickyFeatures(mergedStickyFeatures) - .stickyVariables(mergedStickyVariables) - .defaultVariationValue(options.getDefaultVariationValue()) - .defaultVariableValue(options.getDefaultVariableValue(), options.hasDefaultVariableValue()); + private EvaluateOptions.StickyState readStickyState() { + synchronized (stateLock) { + return new EvaluateOptions.StickyState(stickyFeatures, stickyVariables, Long.toString(stateGeneration.get())); + } } + String getStateGeneration() { return Long.toString(stateGeneration.get()); } + public Evaluation evaluateFlag(String featureKey, Map context, OverrideOptions options) { EvaluateOptions evaluateOptions = getEvaluationDependencies(context, options) .type(Evaluation.TYPE_FLAG) @@ -795,11 +863,8 @@ public Evaluation evaluateVariable(String variableKey, Map conte public Object getVariable(String variableKey, Map context, OverrideOptions options) { Evaluation evaluation = evaluateVariable(variableKey, context, options); - Object value = evaluation.getVariableValue(); - if (value == null && options != null && options.hasDefaultVariableValue()) value = options.getDefaultVariableValue(); - if (value instanceof String && evaluation.getVariable() != null && VariableType.JSON == evaluation.getVariable().getType()) { - try { return OBJECT_MAPPER.readValue((String) value, Object.class); } catch (Exception ignored) { return null; } - } + Object value = Evaluate.normalizeGetterValue(evaluation, this.diagnostics); + if (!evaluation.hasVariableValue() && options != null && options.hasDefaultVariableValue()) value = options.getDefaultVariableValue(); return value; } public Object getVariable(String variableKey, Map context) { return getVariable(variableKey, context, null); } @@ -823,22 +888,7 @@ public Object getVariable(String featureKey, String variableKey, Map setup; - private Function before; private Function beforeEvaluation; private ConfigureBucketKey bucketKey; private ConfigureBucketValue bucketValue; - private BiFunction after; private BiFunction afterEvaluation; private Runnable close; @@ -24,21 +22,17 @@ public FeaturevisorModule(String name) { public String getId() { return id; } public String getName() { return name; } public Consumer getSetup() { return setup; } - public Function getBefore() { return before; } public Function getBeforeEvaluation() { return beforeEvaluation; } public ConfigureBucketKey getBucketKey() { return bucketKey; } public ConfigureBucketValue getBucketValue() { return bucketValue; } - public BiFunction getAfter() { return after; } public BiFunction getAfterEvaluation() { return afterEvaluation; } public Runnable getClose() { return close; } public void setName(String name) { this.name = name; } public void setSetup(Consumer setup) { this.setup = setup; } - public void setBefore(Function before) { this.before = before; } public void setBeforeEvaluation(Function value) { this.beforeEvaluation = value; } public void setBucketKey(ConfigureBucketKey bucketKey) { this.bucketKey = bucketKey; } public void setBucketValue(ConfigureBucketValue bucketValue) { this.bucketValue = bucketValue; } - public void setAfter(BiFunction after) { this.after = after; } public void setAfterEvaluation(BiFunction value) { this.afterEvaluation = value; } public void setClose(Runnable close) { this.close = close; } @@ -47,10 +41,6 @@ public FeaturevisorModule setup(Consumer setup) { return this; } - public FeaturevisorModule before(Function before) { - this.before = before; - return this; - } public FeaturevisorModule beforeEvaluation(Function value) { this.beforeEvaluation = value; return this; } public FeaturevisorModule bucketKey(ConfigureBucketKey bucketKey) { @@ -63,10 +53,6 @@ public FeaturevisorModule bucketValue(ConfigureBucketValue bucketValue) { return this; } - public FeaturevisorModule after(BiFunction after) { - this.after = after; - return this; - } public FeaturevisorModule afterEvaluation(BiFunction value) { this.afterEvaluation = value; return this; } public FeaturevisorModule close(Runnable close) { diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Force.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Force.java index 569c015..3772016 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Force.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Force.java @@ -4,6 +4,10 @@ import java.util.Map; public class Force { + @JsonProperty("requiredFeatures") private java.util.List requiredFeatures; + public java.util.List getRequiredFeatures() { return requiredFeatures; } + public void setRequiredFeatures(java.util.List value) { requiredFeatures = value; } + @JsonProperty("conditions") private Object conditions; // Can be Condition, List diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/GlobalVariable.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/GlobalVariable.java index b2f0e60..bc71a7c 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/GlobalVariable.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/GlobalVariable.java @@ -3,9 +3,31 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSetter; import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonAnyGetter; import java.util.List; public class GlobalVariable { + @JsonProperty("deprecated") private Boolean deprecated; + public Boolean getDeprecated() { return deprecated; } + public void setDeprecated(Boolean value) { deprecated = value; } + @JsonProperty("compose") private Object compose; + public Object getCompose() { return compose; } + public void setCompose(Object value) { compose = value; } + + @JsonIgnore private Object conditions; + private boolean conditionsPresent; + @JsonIgnore + public Object getConditions() { return conditions; } + public void setConditions(Object value) { conditions = value; conditionsPresent = value != null; } + boolean hasConditions() { return conditionsPresent; } + @JsonSetter("conditions") + private void readConditions(Object value) { conditions = value; conditionsPresent = true; } + @JsonAnyGetter + private java.util.Map conditionsProperty() { + return conditionsPresent ? java.util.Collections.singletonMap("conditions", conditions) : java.util.Map.of(); + } + @JsonProperty("hash") private String hash; @JsonProperty("type") private VariableType type; private Object defaultValue; @@ -14,7 +36,9 @@ public class GlobalVariable { private boolean disabledValueSet; @JsonProperty("useDefaultWhenDisabled") private Boolean useDefaultWhenDisabled; @JsonProperty("requiredFeatures") private List requiredFeatures; + @JsonProperty("requiredVariables") private Object requiredVariables; @JsonProperty("overrides") private List overrides; + @JsonProperty("overrideProgram") private List overrideProgram; public String getHash() { return hash; } public void setHash(String hash) { this.hash = hash; } @@ -32,6 +56,10 @@ public class GlobalVariable { public void setUseDefaultWhenDisabled(Boolean value) { this.useDefaultWhenDisabled = value; } public List getRequiredFeatures() { return requiredFeatures; } public void setRequiredFeatures(List value) { this.requiredFeatures = value; } + public Object getRequiredVariables() { return requiredVariables; } + public void setRequiredVariables(Object value) { this.requiredVariables = value; } public List getOverrides() { return overrides; } public void setOverrides(List overrides) { this.overrides = overrides; } + public List getOverrideProgram() { return overrideProgram; } + public void setOverrideProgram(List value) { this.overrideProgram = value; } } diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/InstanceEvaluationDataProvider.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/InstanceEvaluationDataProvider.java index c349973..a7d3bc5 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/InstanceEvaluationDataProvider.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/InstanceEvaluationDataProvider.java @@ -12,6 +12,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.util.Map; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.ArrayList; import java.util.regex.Pattern; @@ -22,6 +23,7 @@ * Handles reading and parsing datafile content */ class InstanceEvaluationDataProvider { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); /** * Options for creating a InstanceEvaluationDataProvider @@ -75,9 +77,40 @@ public ForceResult(Force force, Integer forceIndex) { private Map features; private Map variables; private DiagnosticReporter diagnostics; + private Dependencies.Index dependencyIndex; + + synchronized Dependencies.Index getDependencyIndex() { + if (dependencyIndex == null) dependencyIndex = Dependencies.create(getDatafile()); + return dependencyIndex; + } + + synchronized Dependencies.Index deriveDependencyIndex(InstanceEvaluationDataProvider previous) { + if (dependencyIndex == null) dependencyIndex = Dependencies.update( + previous.getDependencyIndex(), previous.getDatafile(), getDatafile()); + return dependencyIndex; + } + + synchronized int getCachedDependencyIndexCount() { return dependencyIndex == null ? 0 : 1; } // Cache for regex patterns to avoid creating new objects for the same regex private Map regexCache; + private Map parsedExpressionCache; + private static final class EntityCache { + private T value; + T get(java.util.function.Supplier> normalize, InstanceEvaluationDataProvider owner) { + Normalize.Result result; + synchronized (this) { + if (value != null) return value; + result = normalize.get(); + value = result.value; + } + owner.reportErrors(result.errors); + return result.value; + } + } + private final Map> normalizedFeatures = new ConcurrentHashMap<>(); + private final Map> normalizedSegments = new ConcurrentHashMap<>(); + private final Map> normalizedVariables = new ConcurrentHashMap<>(); public InstanceEvaluationDataProvider(InstanceEvaluationDataProviderOptions options) { DatafileContent datafile = options.getDatafile(); @@ -96,7 +129,11 @@ public InstanceEvaluationDataProvider(InstanceEvaluationDataProviderOptions opti this.features = new HashMap<>(); } if (this.variables == null) { this.variables = new HashMap<>(); } + features.keySet().forEach(key -> normalizedFeatures.put(key, new EntityCache<>())); + segments.keySet().forEach(key -> normalizedSegments.put(key, new EntityCache<>())); + variables.keySet().forEach(key -> normalizedVariables.put(key, new EntityCache<>())); this.regexCache = new ConcurrentHashMap<>(); + this.parsedExpressionCache = new ConcurrentHashMap<>(); } public String getRevision() { @@ -125,9 +162,7 @@ public Segment getSegment(String segmentKey) { return null; } - segment.setConditions(parseConditionsIfStringified(segment.getConditions())); - - return segment; + return normalizedSegments.get(segmentKey).get(() -> Normalize.segment(segmentKey, segment, false), this); } public List getFeatureKeys() { @@ -135,11 +170,35 @@ public List getFeatureKeys() { } public Feature getFeature(String featureKey) { - return features.get(featureKey); + Feature source = features.get(featureKey); + return source == null ? null : normalizedFeatures.get(featureKey).get(() -> Normalize.feature(featureKey, source, false), this); } public List getGlobalVariableKeys() { return new ArrayList<>(variables.keySet()); } - public GlobalVariable getGlobalVariable(String key) { return variables.get(key); } + public GlobalVariable getGlobalVariable(String key) { + GlobalVariable source = variables.get(key); + return source == null ? null : normalizedVariables.get(key).get(() -> Normalize.variable(key, source, false), this); + } + + void reportErrors(List> errors) { + for (Map error : errors) { + Map details = new LinkedHashMap<>(error); + details.put("code", "conditions_parse_error"); + diagnostics.error("Error parsing conditions", details); + } + } + + void retainCaches(InstanceEvaluationDataProvider previous, DatafileContent incoming) { + previous.normalizedFeatures.forEach((key, value) -> { + if (!incoming.getFeatures().containsKey(key) && features.get(key) == previous.features.get(key)) normalizedFeatures.put(key, value); + }); + previous.normalizedSegments.forEach((key, value) -> { + if (!incoming.getSegments().containsKey(key) && segments.get(key) == previous.segments.get(key)) normalizedSegments.put(key, value); + }); + previous.normalizedVariables.forEach((key, value) -> { + if ((incoming.getVariables() == null || !incoming.getVariables().containsKey(key)) && variables.get(key) == previous.variables.get(key)) normalizedVariables.put(key, value); + }); + } public List getVariableKeys(String featureKey) { Feature feature = getFeature(featureKey); @@ -170,7 +229,7 @@ public Pattern getRegex(String regexString, String regexFlags) { } try { - Pattern regex = Pattern.compile(regexString, getPatternFlags(flags)); + Pattern regex = Pattern.compile(Conditions.normalizePortableRegex(regexString), getPatternFlags(flags)); regexCache.put(cacheKey, regex); return regex; } catch (IllegalArgumentException e) { @@ -248,7 +307,7 @@ public boolean allConditionsAreMatched(Object conditions, Map co Map details = new HashMap<>(); details.put("code", "condition_match_error"); details.put("originalError", e); - details.put("condition", conditionMap); + details.put("condition", copyDiagnosticCondition(conditionMap)); details.put("context", context); diagnostics.warn(e.getMessage(), details); return false; @@ -300,6 +359,23 @@ public boolean allConditionsAreMatched(Object conditions, Map co return false; } + private static Object copyDiagnosticCondition(Object value) { + // Diagnostic callbacks must not mutate cached expression trees. + if (value instanceof Map) { + Map copy = new LinkedHashMap<>(); + ((Map) value).forEach((key, item) -> copy.put(key, copyDiagnosticCondition(item))); + return copy; + } + if (value instanceof List) { + List copy = new ArrayList<>(((List) value).size()); + for (Object item : (List) value) { + copy.add(copyDiagnosticCondition(item)); + } + return copy; + } + return value; + } + public boolean segmentIsMatched(Segment segment, Map context) { return allConditionsAreMatched(segment.getConditions(), context); } @@ -361,6 +437,21 @@ public Traffic getMatchedTraffic(List traffic, Map cont .orElse(null); } + public Traffic getMatchedTraffic(List traffic, EvaluateOptions options) { + if (traffic != null) for (Traffic entry : traffic) { + if (Evaluate.conditionsAreMatched(entry.getConditions(), options)) return entry; + } + return null; + } + + public ForceResult getMatchedForce(Feature feature, EvaluateOptions options) { + if (feature.getForce() != null) for (int index = 0; index < feature.getForce().size(); index++) { + Force force = feature.getForce().get(index); + if (Evaluate.conditionsAreMatched(force.getConditions(), options)) return new ForceResult(force, index); + } + return new ForceResult(); + } + public Allocation getMatchedAllocation(Traffic traffic, int bucketValue) { if (traffic.getAllocation() == null) { return null; @@ -430,9 +521,22 @@ public Object parseConditionsIfStringified(Object conditions) { return conditions; } + Object cached = parsedExpressionCache.get(conditionsStr); + if (cached != null) { + return cached; + } + + Object parsed = parseConditionsUncached(conditionsStr); + if (parsed != null && parsed != conditions) { + Object existing = parsedExpressionCache.putIfAbsent(conditionsStr, parsed); + return existing != null ? existing : parsed; + } + return parsed; + } + + private Object parseConditionsUncached(String conditions) { try { - ObjectMapper mapper = new ObjectMapper(); - return mapper.readValue(conditionsStr, Object.class); + return OBJECT_MAPPER.readValue(conditions, Object.class); } catch (Exception e) { Map details = new HashMap<>(); details.put("code", "conditions_parse_error"); @@ -447,9 +551,17 @@ public Object parseSegmentsIfStringified(Object segments) { if (segments instanceof String) { String segmentsStr = (String) segments; if (segmentsStr.startsWith("{") || segmentsStr.startsWith("[")) { + Object cached = parsedExpressionCache.get(segmentsStr); + if (cached != null) { + return cached; + } try { - ObjectMapper mapper = new ObjectMapper(); - return mapper.readValue(segmentsStr, Object.class); + Object parsed = OBJECT_MAPPER.readValue(segmentsStr, Object.class); + if (parsed == null) { + return null; + } + Object existing = parsedExpressionCache.putIfAbsent(segmentsStr, parsed); + return existing != null ? existing : parsed; } catch (Exception e) { throw new IllegalArgumentException("Error parsing segments", e); } @@ -458,4 +570,10 @@ public Object parseSegmentsIfStringified(Object segments) { return segments; } + + synchronized void clearCaches() { + dependencyIndex = null; + regexCache.clear(); + parsedExpressionCache.clear(); + } } diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/ModulesManager.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/ModulesManager.java index 4475e4f..1eadda0 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/ModulesManager.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/ModulesManager.java @@ -4,7 +4,8 @@ import java.util.List; final class ModulesManager { - private final List modules = new ArrayList<>(); + private final List modules = new java.util.concurrent.CopyOnWriteArrayList<>(); + private volatile long revision; private final FeaturevisorDiagnosticReporter diagnosticReporter; private final FeaturevisorModuleApiFactory moduleApiFactory; private final java.util.function.Consumer clearModuleDiagnosticSubscriptions; @@ -87,6 +88,7 @@ public Runnable add(FeaturevisorModule module) { } modules.add(module); + revision++; return () -> remove(module); } @@ -110,6 +112,7 @@ public void remove(FeaturevisorModule module) { return; } if (modules.remove(module)) { + revision++; if (clearModuleDiagnosticSubscriptions != null) { clearModuleDiagnosticSubscriptions.accept(module); } @@ -121,14 +124,14 @@ public List getAll() { return new ArrayList<>(modules); } + // CopyOnWriteArrayList captures membership when the phase obtains its iterator. + Iterable phaseModules() { return modules; } + + long getRevision() { return revision; } + public EvaluateOptions executeBeforeModules(EvaluateOptions options) { EvaluateOptions currentOptions = options; - for (FeaturevisorModule module : modules) { - if (!currentOptions.isGlobalVariable() && module.getBefore() != null) { - currentOptions = module.getBefore().apply(currentOptions); - } - } - for (FeaturevisorModule module : modules) { + for (FeaturevisorModule module : phaseModules()) { if (module.getBeforeEvaluation() != null) { currentOptions = module.getBeforeEvaluation().apply(currentOptions); } @@ -138,16 +141,11 @@ public EvaluateOptions executeBeforeModules(EvaluateOptions options) { public Evaluation executeAfterModules(Evaluation evaluation, EvaluateOptions options) { Evaluation currentEvaluation = evaluation; - for (FeaturevisorModule module : modules) { + for (FeaturevisorModule module : phaseModules()) { if (module.getAfterEvaluation() != null) { currentEvaluation = module.getAfterEvaluation().apply(currentEvaluation, options); } } - for (FeaturevisorModule module : modules) { - if (!options.isGlobalVariable() && module.getAfter() != null) { - currentEvaluation = module.getAfter().apply(currentEvaluation, options); - } - } return currentEvaluation; } @@ -182,6 +180,7 @@ public int executeBucketValueModules(ConfigureBucketValueOptions options) { } public void closeAll() { + if (!modules.isEmpty()) revision++; for (FeaturevisorModule module : new ArrayList<>(modules)) { if (clearModuleDiagnosticSubscriptions != null) { clearModuleDiagnosticSubscriptions.accept(module); diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Normalize.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Normalize.java new file mode 100644 index 0000000..69304aa --- /dev/null +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Normalize.java @@ -0,0 +1,213 @@ +package com.featurevisor.sdk; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.DeserializationFeature; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Converts selectors without changing definitions or their value ownership. */ +final class Normalize { + static final Object INVALID = java.util.Collections.unmodifiableMap(Map.of("or", List.of())); + private static final ObjectMapper JSON = new ObjectMapper() + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + + static final class Result { + final T value; + final List> errors; + Result(T value, List> errors) { this.value = value; this.errors = errors; } + } + + private final String kind; + private final String key; + private final boolean legacy; + private final List> errors = new ArrayList<>(); + + private Normalize(String kind, String key, boolean legacy) { + this.kind = kind; this.key = key; this.legacy = legacy; + } + + @SuppressWarnings("unchecked") + static T copy(T source) { + if (source == null) return null; + try { + T result = (T) source.getClass().getDeclaredConstructor().newInstance(); + for (Field field : source.getClass().getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers())) continue; + field.setAccessible(true); + field.set(result, field.get(source)); + } + return result; + } catch (ReflectiveOperationException error) { throw new IllegalArgumentException(error); } + } + + static boolean stringified(String value) { + String text = value.stripLeading(); + return text.startsWith("{") || text.startsWith("[") || text.startsWith("\""); + } + + private Object parse(Object value) { + if (!(value instanceof String) || !stringified((String) value)) return value; + try { return JSON.readValue((String) value, Object.class); } + catch (Exception error) { + Map details = new LinkedHashMap<>(); + details.put("conditions", value); + details.put("entityType", kind); + details.put("entityKey", key); + details.put("originalError", error); + errors.add(details); + return INVALID; + } + } + + private static Object old(Object value, boolean features) { + if (value == INVALID) return value; + if (value instanceof String) return features ? Map.of("feature", value) : value; + if (value instanceof List) { + List values = (List) value; + if (values.isEmpty()) return "*"; + List result = new ArrayList<>(); + for (Object child : values) result.add(old(child, features)); + return result; + } + if (value instanceof Map) { + Map map = (Map) value; + for (String group : List.of("and", "or", "not")) { + if (!(map.get(group) instanceof List)) continue; + List children = (List) map.get(group); + if (group.equals("and") && children.isEmpty()) return "*"; + List result = new ArrayList<>(); + for (Object child : children) result.add(old(child, features)); + return Map.of(group, result); + } + } + return value; + } + + private Object select(Object conditions, Object segments, List features, boolean oldConditions) { + return select(conditions, segments, features, oldConditions, conditions != null); + } + + private Object select(Object conditions, Object segments, List features, boolean oldConditions, boolean present) { + List parts = new ArrayList<>(); + if (present) { + Object parsed = parse(conditions); + parts.add(oldConditions ? old(parsed, false) : parsed); + } + if (segments != null) parts.add(old(parse(segments), false)); + if (features != null && !features.isEmpty()) parts.add(old(features, true)); + for (Object part : parts) if (part == INVALID) return INVALID; + if (parts.isEmpty()) return null; + if (parts.size() == 1) return parts.get(0); + List combined = new ArrayList<>(); + for (Object part : parts) { + if (part instanceof List && !((List) part).isEmpty()) combined.addAll((List) part); + else combined.add(part instanceof List ? Map.of("and", List.of()) : part); + } + return combined; + } + + private VariableOverride override(VariableOverride source, boolean oldConditions) { + VariableOverride value = copy(source); + value.setConditions(select(source.getConditions(), source.getSegments(), source.getRequiredFeatures(), oldConditions)); + value.setSegments(null); value.setRequiredFeatures(null); value.setRequiredVariables(null); + return value; + } + + private List overrides(List entries, boolean oldConditions) { + if (entries == null) return null; + List result = new ArrayList<>(); + for (VariableOverride entry : entries) result.add(override(entry, oldConditions)); + return result; + } + + private Map> groups(Map> entries) { + if (entries == null) return null; + Map> result = new LinkedHashMap<>(); + entries.forEach((name, values) -> result.put(name, overrides(values, legacy))); + return result; + } + + static Result feature(String key, Feature source, boolean legacy) { + Normalize n = new Normalize("feature", key, legacy); + Feature value = copy(source); + Object conditions = n.select(source.getConditions(), null, source.getRequiredFeatures(), false, source.hasConditions()); + if (conditions != null) value.setConditions(conditions); + value.setRequiredFeatures(null); value.setRequiredVariables(null); + if (source.getForce() != null) { + List forces = new ArrayList<>(); + for (Force entry : source.getForce()) { + Force force = copy(entry); + force.setConditions(n.select(entry.getConditions(), entry.getSegments(), entry.getRequiredFeatures(), legacy)); + force.setSegments(null); force.setRequiredFeatures(null); + forces.add(force); + } + value.setForce(forces); + } + if (source.getTraffic() != null) { + List traffic = new ArrayList<>(); + for (Traffic entry : source.getTraffic()) { + Traffic rule = copy(entry); + rule.setConditions(n.select(entry.getConditions(), entry.getSegments(), entry.getRequiredFeatures(), false)); + rule.setSegments(null); rule.setRequiredFeatures(null); + rule.setVariableOverrides(n.groups(entry.getVariableOverrides())); + traffic.add(rule); + } + value.setTraffic(traffic); + } + if (source.getVariations() != null) { + List variations = new ArrayList<>(); + for (Variation entry : source.getVariations()) { + Variation variation = copy(entry); + variation.setVariableOverrides(n.groups(entry.getVariableOverrides())); + variations.add(variation); + } + value.setVariations(variations); + } + return new Result<>(value, n.errors); + } + + static Result segment(String key, Segment source, boolean legacy) { + Normalize n = new Normalize("segment", key, legacy); + Segment value = copy(source); + Object conditions = n.parse(source.getConditions()); + value.setConditions(legacy ? old(conditions, false) : conditions); + return new Result<>(value, n.errors); + } + + static Result variable(String key, GlobalVariable source, boolean legacy) { + Normalize n = new Normalize("variable", key, legacy); + GlobalVariable value = copy(source); + Object conditions = n.select(source.getConditions(), null, source.getRequiredFeatures(), false, source.hasConditions()); + if (conditions != null) value.setConditions(conditions); + value.setRequiredFeatures(null); value.setRequiredVariables(null); + value.setOverrides(n.overrides(source.getOverrides(), legacy)); + value.setOverrideProgram(n.overrides(source.getOverrideProgram(), false)); + return new Result<>(value, n.errors); + } + + static DatafileContent incoming(DatafileContent source, List> errors) { + DatafileContent result = copy(source); + if (!"2".equals(source.getSchemaVersion())) return result; + Map features = new LinkedHashMap<>(); + source.getFeatures().forEach((key, value) -> { + Result normalized = feature(key, value, true); + features.put(key, normalized.value); errors.addAll(normalized.errors); + }); + Map segments = new LinkedHashMap<>(); + source.getSegments().forEach((key, value) -> { + Result normalized = segment(key, value, true); + segments.put(key, normalized.value); errors.addAll(normalized.errors); + }); + Map variables = new LinkedHashMap<>(); + if (source.getVariables() != null) source.getVariables().forEach((key, value) -> { + Result normalized = variable(key, value, true); + variables.put(key, normalized.value); errors.addAll(normalized.errors); + }); + result.setFeatures(features); result.setSegments(segments); result.setVariables(variables); + return result; + } +} diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Operator.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Operator.java index 60069cf..2aef686 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Operator.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Operator.java @@ -8,6 +8,10 @@ public enum Operator { NOT_EQUALS("notEquals"), EXISTS("exists"), NOT_EXISTS("notExists"), + IS_NULL("isNull"), + NOT_NULL("notNull"), + EMPTY("empty"), + NOT_EMPTY("notEmpty"), // numeric GREATER_THAN("greaterThan"), @@ -36,6 +40,8 @@ public enum Operator { // array of strings INCLUDES("includes"), NOT_INCLUDES("notIncludes"), + INCLUDES_ANY("includesAny"), + NOT_INCLUDES_ANY("notIncludesAny"), // regex MATCHES("matches"), diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/RequiredWithVariation.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/RequiredWithVariation.java deleted file mode 100644 index f202259..0000000 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/RequiredWithVariation.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.featurevisor.sdk; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public class RequiredWithVariation { - @JsonProperty("key") - private String key; - - @JsonProperty("variation") - private String variation; - - // Constructors - public RequiredWithVariation() {} - - public RequiredWithVariation(String key, String variation) { - this.key = key; - this.variation = variation; - } - - // Getters and Setters - public String getKey() { - return key; - } - - public void setKey(String key) { - this.key = key; - } - - public String getVariation() { - return variation; - } - - public void setVariation(String variation) { - this.variation = variation; - } -} diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Traffic.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Traffic.java index bd7c75c..9c86af9 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Traffic.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Traffic.java @@ -5,6 +5,14 @@ import java.util.Map; public class Traffic { + @JsonProperty("requiredFeatures") private java.util.List requiredFeatures; + public java.util.List getRequiredFeatures() { return requiredFeatures; } + public void setRequiredFeatures(java.util.List value) { requiredFeatures = value; } + + @JsonProperty("conditions") private Object conditions; + public Object getConditions() { return conditions; } + public void setConditions(Object value) { conditions = value; } + @JsonProperty("key") private String key; diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableKey.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableKey.java deleted file mode 100644 index 4265991..0000000 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableKey.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.featurevisor.sdk; - -/** - * VariableKey type alias - * Corresponds to: export type VariableKey = string; - */ -public class VariableKey { - private String value; - - public VariableKey(String value) { - this.value = value; - } - - public String getValue() { - return value; - } - - public void setValue(String value) { - this.value = value; - } - - @Override - public String toString() { - return value; - } - - public static VariableKey of(String value) { - return new VariableKey(value); - } -} diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableMutationOperation.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableMutationOperation.java new file mode 100644 index 0000000..768b701 --- /dev/null +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableMutationOperation.java @@ -0,0 +1,30 @@ +package com.featurevisor.sdk; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; + +/** A compiled variable mutation emitted by the Featurevisor datafile builder. */ +public final class VariableMutationOperation { + @JsonProperty("op") + private String op; + + @JsonProperty("path") + private Object path; + + private Object value; + private boolean valueSet; + + public String getOp() { return op; } + public void setOp(String op) { this.op = op; } + public Object getPath() { return path; } + public void setPath(Object path) { this.path = path; } + public Object getValue() { return value; } + public boolean hasValue() { return valueSet; } + + @JsonSetter(value = "value", nulls = Nulls.SET) + public void setValue(Object value) { + this.value = value; + this.valueSet = true; + } +} diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableMutations.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableMutations.java new file mode 100644 index 0000000..b827c42 --- /dev/null +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableMutations.java @@ -0,0 +1,225 @@ +package com.featurevisor.sdk; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Immutable interpreter for compiled variable override operations. */ +final class VariableMutations { + private static final List OPERATIONS = List.of( + "set", "append", "prepend", "appendUnique", "removeValue", "before", "after", "remove" + ); + private static final List DANGEROUS_KEYS = List.of("__proto__", "prototype", "constructor"); + + private VariableMutations() {} + + static Object apply(Object value, List operations) { + if (operations == null) throw new IllegalArgumentException("Invalid variable mutation operations"); + Object current = value; + for (VariableMutationOperation operation : operations) { + validate(operation); + current = updateAtPath(current, operation, 0); + } + return current; + } + + private static void validate(VariableMutationOperation operation) { + if (operation == null || operation.getOp() == null || !(operation.getPath() instanceof List) + || !OPERATIONS.contains(operation.getOp())) { + throw new IllegalArgumentException("Invalid variable mutation operation"); + } + if (!"remove".equals(operation.getOp()) && !operation.hasValue()) { + throw new IllegalArgumentException("Variable mutation operation requires a value"); + } + if (("appendUnique".equals(operation.getOp()) || "removeValue".equals(operation.getOp())) + && (!isPrimitive(operation.getValue()) || isNonFinite(operation.getValue()))) { + throw new IllegalArgumentException("Variable mutation operation requires a primitive value"); + } + for (Object part : (List) operation.getPath()) { + String property = null; + if (part instanceof String) { + property = (String) part; + } else if (part instanceof Number && isIndex(part)) { + // Valid numeric array index. + } else if (part instanceof Map) { + property = selectorProperty(part); + } else { + throw new IllegalArgumentException("Invalid variable mutation path"); + } + if (property != null && DANGEROUS_KEYS.contains(property)) { + throw new IllegalArgumentException("Unsafe variable mutation key"); + } + } + } + + private static boolean isPrimitive(Object value) { + return value == null || value instanceof String || value instanceof Number || value instanceof Boolean; + } + + private static boolean isNonFinite(Object value) { + return value instanceof Number && !Double.isFinite(((Number) value).doubleValue()); + } + + private static boolean isIndex(Object value) { + if (!(value instanceof Number)) return false; + double number = ((Number) value).doubleValue(); + return Double.isFinite(number) && number >= 0 && number == Math.rint(number) && number <= Integer.MAX_VALUE; + } + + private static String selectorProperty(Object part) { + if (!(part instanceof Map)) throw new IllegalArgumentException("Invalid variable mutation path"); + Object selectValue = ((Map) part).get("select"); + if (!(selectValue instanceof Map)) throw new IllegalArgumentException("Invalid variable mutation path"); + Map select = (Map) selectValue; + Object property = select.get("property"); + Object values = select.get("values"); + if (!(property instanceof String) || !(values instanceof List)) { + throw new IllegalArgumentException("Invalid variable mutation path"); + } + for (Object value : (List) values) { + if (!isPrimitive(value) || isNonFinite(value)) { + throw new IllegalArgumentException("Invalid variable mutation path"); + } + } + return (String) property; + } + + private static int selectedIndex(List list, Object part) { + Map select = (Map) ((Map) part).get("select"); + String property = (String) select.get("property"); + List values = (List) select.get("values"); + for (int index = 0; index < list.size(); index++) { + Object candidate = list.get(index); + if (!(candidate instanceof Map) || !((Map) candidate).containsKey(property)) continue; + Object propertyValue = ((Map) candidate).get(property); + if (!isPrimitive(propertyValue)) continue; + for (Object value : values) if (scalarEquals(propertyValue, value)) return index; + } + return -1; + } + + private static Object key(Object container, Object part) { + if (part instanceof String) { + if (!(container instanceof Map)) throw new IllegalArgumentException("Variable mutation expected an object"); + return part; + } + if (!(container instanceof List)) throw new IllegalArgumentException("Variable mutation expected an array"); + return part instanceof Number ? ((Number) part).intValue() : selectedIndex((List) container, part); + } + + private static boolean exists(Object container, Object key) { + return key instanceof String + ? ((Map) container).containsKey(key) + : (Integer) key >= 0 && (Integer) key < ((List) container).size(); + } + + private static Object child(Object container, Object key) { + return key instanceof String + ? ((Map) container).get(key) + : ((List) container).get((Integer) key); + } + + private static Object replace(Object container, Object key, Object value) { + if (container instanceof List) { + List next = new ArrayList<>((List) container); + int index = (Integer) key; + if (index == next.size()) next.add(value); else next.set(index, value); + return next; + } + Map next = new LinkedHashMap<>(); + ((Map) container).forEach((mapKey, mapValue) -> next.put(String.valueOf(mapKey), mapValue)); + next.put((String) key, value); + return next; + } + + private static Object updateArray(List input, String op, Object item) { + if ("removeValue".equals(op)) { + boolean found = input.stream().anyMatch(value -> scalarEquals(value, item)); + if (!found) return input; + List next = new ArrayList<>(); + for (Object value : input) if (!scalarEquals(value, item)) next.add(value); + return next; + } + if ("appendUnique".equals(op) && input.stream().anyMatch(value -> scalarEquals(value, item))) return input; + List next = new ArrayList<>(input.size() + 1); + if ("prepend".equals(op)) next.add(item); + next.addAll(input); + if (!"prepend".equals(op)) next.add(item); + return next; + } + + private static Object updateFinal(Object container, Object part, VariableMutationOperation operation) { + if (container == null) return null; + String op = operation.getOp(); + if (("before".equals(op) || "after".equals(op)) && part instanceof String) { + throw new IllegalArgumentException("Variable mutation before/after requires an array element path"); + } + Object key = key(container, part); + boolean found = exists(container, key); + if ("remove".equals(op)) { + if (!found) return container; + if (container instanceof List) { + List next = new ArrayList<>((List) container); + next.remove((int) (Integer) key); + return next; + } + Map next = new LinkedHashMap<>(); + ((Map) container).forEach((mapKey, value) -> next.put(String.valueOf(mapKey), value)); + next.remove(key); + return next; + } + Object item = operation.getValue(); + if ("set".equals(op)) { + if (key instanceof Integer && (Integer) key > ((List) container).size()) return container; + if (found && child(container, key) == item) return container; + return replace(container, key, item); + } + if ("before".equals(op) || "after".equals(op)) { + if (!found) return container; + List next = new ArrayList<>((List) container); + next.add((Integer) key + ("after".equals(op) ? 1 : 0), item); + return next; + } + Object current = found ? child(container, key) : null; + if (current == null && key instanceof Integer) return container; + if (current == null && "removeValue".equals(op)) return container; + if (current != null && !(current instanceof List)) { + throw new IllegalArgumentException("Variable mutation expected an array"); + } + Object next = updateArray(current == null ? List.of() : (List) current, op, item); + return next == current ? container : replace(container, key, next); + } + + private static Object updateAtPath(Object value, VariableMutationOperation operation, int offset) { + List path = (List) operation.getPath(); + if (offset == path.size()) { + if ("set".equals(operation.getOp())) return operation.getValue(); + if ("remove".equals(operation.getOp())) throw new IllegalArgumentException("Cannot remove the root variable value"); + if ("before".equals(operation.getOp()) || "after".equals(operation.getOp())) { + throw new IllegalArgumentException("Variable mutation before/after requires an array element path"); + } + if (value == null) return null; + if (!(value instanceof List)) throw new IllegalArgumentException("Variable mutation expected an array"); + return updateArray((List) value, operation.getOp(), operation.getValue()); + } + if (offset == path.size() - 1) return updateFinal(value, path.get(offset), operation); + if (value == null) return null; + Object key = key(value, path.get(offset)); + if (!exists(value, key)) return value; + Object current = child(value, key); + if (current == null) return value; + Object next = updateAtPath(current, operation, offset + 1); + return next == current ? value : replace(value, key, next); + } + + private static boolean scalarEquals(Object left, Object right) { + if (left == null || right == null) return left == right; + if (left instanceof Number && right instanceof Number) { + double a = ((Number) left).doubleValue(); + double b = ((Number) right).doubleValue(); + return !Double.isNaN(a) && !Double.isNaN(b) && a == b; + } + return left.getClass() == right.getClass() && left.equals(right); + } +} diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableOverride.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableOverride.java index f35c4f0..3e6f063 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableOverride.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableOverride.java @@ -14,6 +14,10 @@ public class VariableOverride { @JsonProperty("segments") private Object segments; // Can be GroupSegment, List @JsonProperty("requiredFeatures") private java.util.List requiredFeatures; + @JsonProperty("requiredVariables") private Object requiredVariables; + @JsonProperty("operations") private java.util.List operations; + @JsonProperty("onMatch") private Object onMatch; + @JsonProperty("onMiss") private Object onMiss; // Constructors public VariableOverride() {} @@ -52,4 +56,12 @@ public void setSegments(Object segments) { public void setKeyPath(java.util.List keyPath) { this.keyPath = keyPath; } public java.util.List getRequiredFeatures() { return requiredFeatures; } public void setRequiredFeatures(java.util.List value) { this.requiredFeatures = value; } + public Object getRequiredVariables() { return requiredVariables; } + public void setRequiredVariables(Object value) { this.requiredVariables = value; } + public java.util.List getOperations() { return operations; } + public void setOperations(java.util.List value) { this.operations = value; } + public Object getOnMatch() { return onMatch; } + public void setOnMatch(Object value) { this.onMatch = value; } + public Object getOnMiss() { return onMiss; } + public void setOnMiss(Object value) { this.onMiss = value; } } diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableType.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableType.java index 3b89c38..e7e0673 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableType.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableType.java @@ -11,6 +11,9 @@ public enum VariableType { STRING("string"), @JsonProperty("integer") INTEGER("integer"), + @JsonProperty("number") + NUMBER("number"), + /** Earlier datafiles call number variables double. Both read the same. */ @JsonProperty("double") DOUBLE("double"), @JsonProperty("array") diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/cli/CLIOptionsTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/cli/CLIOptionsTest.java index 8b323f6..d49dc00 100644 --- a/featurevisor-sdk/src/test/java/com/featurevisor/cli/CLIOptionsTest.java +++ b/featurevisor-sdk/src/test/java/com/featurevisor/cli/CLIOptionsTest.java @@ -12,6 +12,56 @@ import static org.junit.jupiter.api.Assertions.assertTrue; public class CLIOptionsTest { + @Test + void aliasesAndValueFormsAreEquivalent() { + String[][] aliases = { + {"keyPattern", "key-pattern", "flag with spaces"}, + {"assertionPattern", "assertion-pattern", "country: nl"}, + {"entityType", "entity-type", "scenario"}, + {"projectDirectoryPath", "project-directory-path", "a project's directory"}, + {"projectDirectoryPath", "root-directory-path", "a project's directory"}, + {"projectDirectoryPath", "rootDirectoryPath", "a project's directory"} + }; + for (String[] alias : aliases) { + for (String spelling : new String[]{alias[0], alias[1]}) { + for (boolean equals : new boolean[]{false, true}) { + String[] args = equals ? new String[]{"test", "--" + spelling + "=" + alias[2]} + : new String[]{"test", "--" + spelling, alias[2]}; + var result = new CommandLine(new CLI()).parseArgs(args); + assertEquals(alias[2], result.matchedOption("--" + alias[0]).getValue()); + } + } + } + for (String[] aliasesPair : new String[][]{ + {"onlyFailures", "only-failures"}, {"showDatafile", "show-datafile"}}) { + for (String spelling : aliasesPair) { + var result = new CommandLine(new CLI()).parseArgs("test", "--" + spelling); + assertEquals(Boolean.TRUE, result.matchedOption("--" + aliasesPair[0]).getValue()); + } + } + for (String option : new String[]{"--n", "-n"}) { + for (String[] args : new String[][]{{"benchmark", option, "2"}, {"benchmark", option + "=2"}}) { + assertEquals(Integer.valueOf(2), new CommandLine(new CLI()).parseArgs(args).matchedOption("--n").getValue()); + } + } + } + + @Test + void repeatedSelectorsPreserveOrderAndConsumeExactlyOneValue() { + var result = new CommandLine(new CLI()).parseArgs("assess-distribution", + "--target=web one", "--target", "mobile", "--target=web one", + "--populate-uuid=userId", "--populateUuid", "device id"); + assertEquals(java.util.List.of("web one", "mobile", "web one"), result.matchedOption("--target").getValue()); + assertEquals(java.util.List.of("userId", "device id"), result.matchedOption("--populateUuid").getValue()); + for (String[] args : new String[][]{ + {"test", "--target=web", "extra"}, {"test", "--populate-uuid=userId", "extra"}, + {"test", "--target"}, {"test", "--populate-uuid"}, {"test", "--unknown"}, + {"test", "--only-failures=invalid"}, {"test", "--n=1.5"}, + {"test", "--key-pattern=a", "--keyPattern=b"}}) { + org.junit.jupiter.api.Assertions.assertThrows(CommandLine.ParameterException.class, + () -> new CommandLine(new CLI()).parseArgs(args), java.util.Arrays.toString(args)); + } + } @Test public void testParseLegacyIgnoredOptions() { @@ -36,8 +86,8 @@ public void testParseLegacyIgnoredOptions() { public void testTargetDatafileCacheKey() { CLI cli = new CLI(); - assertEquals("false-target-checkout", cli.targetDatafileCacheKey(null, "checkout")); - assertEquals("production-target-checkout", cli.targetDatafileCacheKey("production", "checkout")); + assertEquals("[null,\"checkout\"]", cli.targetDatafileCacheKey(null, "checkout")); + assertEquals("[\"production\",\"checkout\"]", cli.targetDatafileCacheKey("production", "checkout")); } @Test @@ -49,6 +99,25 @@ public void testRepeatedTargets() { assertEquals(java.util.Arrays.asList("web", "mobile"), result.matchedOption("--target").getValue()); } + @Test + public void testReportingOptions() { + CLI cli = new CLI(); + CommandLine.ParseResult result = new CommandLine(cli).parseArgs( + "test", + "--case=[null,\"variable\",\"settings.spec\",0,null]", + "--json", + "--pretty", + "--coverage", + "--uncovered" + ); + + assertTrue(result.hasMatchedOption("--case")); + assertTrue(result.hasMatchedOption("--json")); + assertTrue(result.hasMatchedOption("--pretty")); + assertTrue(result.hasMatchedOption("--coverage")); + assertTrue(result.hasMatchedOption("--uncovered")); + } + @Test public void testTargetAssertionSelectsTargetDatafile() { CLI cli = new CLI(); @@ -57,10 +126,10 @@ public void testTargetAssertionSelectsTargetDatafile() { assertion.put("target", "checkout"); Map cache = new HashMap<>(); - cache.put("production", new DatafileContent("2", "base")); - cache.put("production-target-checkout", new DatafileContent("2", "target")); + cache.put(cli.getEnvironmentKey("production"), new DatafileContent("2", "base")); + cache.put(cli.targetDatafileCacheKey("production", "checkout"), new DatafileContent("2", "target")); - assertEquals("production-target-checkout", cli.selectDatafileKeyForAssertion(assertion, cache)); + assertEquals("[\"production\",\"checkout\"]", cli.selectDatafileKeyForAssertion(assertion, cache)); } @Test @@ -71,9 +140,9 @@ public void testTargetAssertionDoesNotFallBackToBaseDatafile() { assertion.put("target", "checkout"); Map cache = new HashMap<>(); - cache.put("production", new DatafileContent("2", "base")); + cache.put(cli.getEnvironmentKey("production"), new DatafileContent("2", "base")); - assertEquals("production-target-checkout", cli.selectDatafileKeyForAssertion(assertion, cache)); + assertEquals("[\"production\",\"checkout\"]", cli.selectDatafileKeyForAssertion(assertion, cache)); } @Test @@ -83,9 +152,37 @@ public void testNoEnvironmentTargetAssertionSelectsTargetDatafile() { assertion.put("target", "checkout"); Map cache = new HashMap<>(); - cache.put("__no_environment__", new DatafileContent("2", "base")); - cache.put("false-target-checkout", new DatafileContent("2", "target")); + cache.put(cli.getEnvironmentKey(null), new DatafileContent("2", "base")); + cache.put(cli.targetDatafileCacheKey(null, "checkout"), new DatafileContent("2", "target")); - assertEquals("false-target-checkout", cli.selectDatafileKeyForAssertion(assertion, cache)); + assertEquals("[null,\"checkout\"]", cli.selectDatafileKeyForAssertion(assertion, cache)); + } + + @Test + public void testCacheKeysPreserveEverySelectionAndRoundTripJson() throws Exception { + CLI cli = new CLI(); + var json = new com.fasterxml.jackson.databind.ObjectMapper(); + String[] names = {null, "", "false", "__no_environment__", "prod", "prod-target-web", + "web", "web-target-mobile", "mobile", "Café 東京 🧪\"\\", "[null,\"web\"]"}; + Map cache = new HashMap<>(); + for (String environment : names) { + for (String target : names) { + String key = target == null ? cli.getEnvironmentKey(environment) + : cli.targetDatafileCacheKey(environment, target); + org.junit.jupiter.api.Assertions.assertNull(cache.put(key, new DatafileContent("2", key))); + assertEquals(json.valueToTree(java.util.Arrays.asList(environment, target)), json.readTree(key)); + } + } + for (String environment : names) { + for (String target : names) { + Map assertion = new HashMap<>(); + assertion.put("environment", environment); + assertion.put("target", target); + String key = cli.selectDatafileKeyForAssertion(assertion, cache); + assertEquals(json.valueToTree(java.util.Arrays.asList(environment, target)), json.readTree(key)); + assertEquals(key, cache.get(key).getRevision()); + } + } + assertEquals(names.length * names.length, cache.size()); } } diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/cli/CLIProcessTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/cli/CLIProcessTest.java new file mode 100644 index 0000000..de9ac29 --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/cli/CLIProcessTest.java @@ -0,0 +1,104 @@ +package com.featurevisor.cli; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class CLIProcessTest { + @Test + void delegatesToTheNearestLocalNodeCliEntry(@TempDir Path directory) throws Exception { + Path entry = directory.resolve("node_modules/@featurevisor/cli/bin.js"); + Files.createDirectories(entry.getParent()); + Files.writeString(entry, ""); + Path project = Files.createDirectories(directory.resolve("packages/flags")); + org.apache.commons.exec.CommandLine command = + CLI.nodeCommandLine(project.toString(), "npx featurevisor list --targets --json"); + assertEquals("node", command.getExecutable()); + assertArrayEquals(new String[] {entry.toString(), "list", "--targets", "--json"}, command.getArguments()); + } + + @Test + void fallsBackToNpxAndLeavesOtherCommandsUnchanged(@TempDir Path directory) { + org.apache.commons.exec.CommandLine command = + CLI.nodeCommandLine(directory.toString(), "npx featurevisor config --json"); + boolean windows = System.getProperty("os.name", "").toLowerCase().startsWith("windows"); + assertEquals(windows ? "npx.cmd" : "npx", command.getExecutable()); + assertArrayEquals(new String[] {"featurevisor", "config", "--json"}, command.getArguments()); + assertEquals("sh", CLI.nodeCommandLine(directory.toString(), "sh script.sh").getExecutable()); + } + + @Test + void rawArgumentsAndWorkingDirectoryAreNotShellSplit(@TempDir Path directory) throws Exception { + Path project = Files.createDirectory(directory.resolve("project's spaced path")); + Path script = project.resolve("echo arguments.sh"); + Files.writeString(script, "pwd\nprintf '%s\\n' \"$@\"\n"); + String context = "{\"name\":\"O'Reilly; $(echo unsafe) 東京\", \"active\":true}"; + String caseId = "[null,\"scenario\",\"a quoted spec\",0,null]"; + String output = new CLI().executeCommandInDirectory(project.toString(), "sh", + List.of(script.toString(), context, "--case=" + caseId, "--set=web team's set")); + assertEquals(project.toRealPath() + "\n" + context + "\n--case=" + caseId + "\n--set=web team's set\n", output); + } + @Test + void warningOnSuccessDoesNotCorruptJson(@TempDir Path directory) throws Exception { + Path script = directory.resolve("warning.sh"); + Files.writeString(script, "printf 'mutation warning\\n' >&2\nprintf '{\"ok\":true}\\n'\n"); + ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + PrintStream original = System.err; + try { + System.setErr(new PrintStream(stderr, true, StandardCharsets.UTF_8.name())); + String output = new CLI().executeCommandInDirectory(directory.toString(), "sh", List.of(script.toString())); + assertEquals("{\"ok\":true}\n", output); + } finally { + System.setErr(original); + } + assertEquals("mutation warning\n", stderr.toString(StandardCharsets.UTF_8.name())); + } + + @Test + void largeStderrOnFailureRetainsExitCodeAndDiagnostics(@TempDir Path directory) throws Exception { + Path script = directory.resolve("failure.sh"); + Files.writeString(script, "head -c 262144 /dev/zero | tr '\\000' W >&2\nprintf 'syntax error at build staging\\n' >&2\nexit 17\n"); + IOException failure = assertThrows(IOException.class, + () -> new CLI().executeCommandInDirectory(directory.toString(), "sh", List.of(script.toString()))); + assertTrue(failure.getMessage().contains("Exit value: 17"), failure.getMessage()); + assertTrue(failure.getMessage().contains("syntax error at build staging")); + assertTrue(failure.getMessage().length() > 262144); + } + + @Test + void utf8OutputAndWarningSurviveDefaultCharset(@TempDir Path directory) throws Exception { + Path script = directory.resolve("unicode.sh"); + Files.writeString(script, "printf '\\303\\251' >&2\nprintf '\\303\\251'"); + ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + PrintStream original = System.err; + try { + System.setErr(new PrintStream(stderr, true, StandardCharsets.UTF_8.name())); + assertEquals("é", new CLI().executeCommandInDirectory(directory.toString(), "sh", List.of(script.toString()))); + assertEquals("é", new CLI().executeCommand("sh " + script)); + } finally { + System.setErr(original); + } + assertEquals("éé", stderr.toString(StandardCharsets.UTF_8.name())); + } + + @Test + void utf8FailureDiagnosticsSurviveDefaultCharset(@TempDir Path directory) throws Exception { + Path script = directory.resolve("unicode-failure.sh"); + Files.writeString(script, "printf '\\303\\251' >&2\nprintf '\\303\\251'\nexit 7\n"); + CLI cli = new CLI(); + IOException inDirectory = assertThrows(IOException.class, + () -> cli.executeCommandInDirectory(directory.toString(), "sh", List.of(script.toString()))); + assertTrue(inDirectory.getMessage().contains("é\né"), inDirectory.getMessage()); + IOException direct = assertThrows(IOException.class, () -> cli.executeCommand("sh " + script)); + assertTrue(direct.getMessage().contains("é\né"), direct.getMessage()); + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/cli/CLIReportingTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/cli/CLIReportingTest.java new file mode 100644 index 0000000..d69fc0f --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/cli/CLIReportingTest.java @@ -0,0 +1,764 @@ +package com.featurevisor.cli; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import picocli.CommandLine; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +public class CLIReportingTest { + private final ObjectMapper json = new ObjectMapper(); + + @Test + void delegatedFiltersRetainRawValuesAndProjectDirectory(@TempDir Path directory) throws Exception { + Path project = Files.createDirectory(directory.resolve("project's spaced path")); + String pattern = "O'Reilly; $(echo unsafe) 東京"; + var calls = new ArrayList>(); + CLI cli = new CLI() { + @Override + String executeCommandInDirectory(String workingDirectory, String command, List arguments) throws IOException { + assertEquals(project.toString(), workingDirectory); + assertFalse(command.contains(pattern)); + assertFalse(command.contains("team's")); + if (command.contains(" config ")) return "{\"environments\":false}"; + assertTrue(arguments.contains("--set=web team's set")); + if (command.contains("--tests")) { + calls.add(new ArrayList<>(arguments)); + return executionProtocol("[]"); + } + if (arguments.contains("build")) return "{\"schemaVersion\":\"2\",\"revision\":\"test\",\"features\":{},\"segments\":{}}"; + return "[]"; + } + }; + Execution execution = execute(cli, "test", "--json", "--root-directory-path", project.toString(), + "--key-pattern", pattern, "--assertion-pattern=country: nl", "--set=web team's set"); + assertEquals(0, execution.code, execution.stdout + execution.stderr); + assertEquals(List.of(List.of("--keyPattern=" + pattern, "--assertionPattern=country: nl", "--set=web team's set")), calls); + } + + @Test + void invalidOptionsFailBeforeDelegationAndKeepStdoutClean() throws Exception { + for (String command : List.of("test", "benchmark", "assess-distribution")) { + for (String[] options : new String[][]{ + {"--unknown"}, {"extra"}, {"--n=0"}, {"-n", "-2"}, {"--n=1.5"}, + {"--inflate=0"}, {"--entity-type=unknown"}, {"--context=[]"}, + {"--context={"}, {"--context=null"}, {"--context={} trailing"}, {"--target="}, {"--populate-uuid="}, + {"--root-directory-path="}, {"--only-failures=invalid"}, {"--target"}, + {"--populate-uuid"}, {"--key-pattern"}}) { + CLI cli = new CLI((directory, delegated) -> { throw new AssertionError(delegated); }); + var args = new ArrayList(List.of(command, "--json")); + args.addAll(List.of(options)); + Execution execution = execute(cli, args.toArray(new String[0])); + assertNotEquals(0, execution.code, args.toString()); + assertEquals("", execution.stdout, args.toString()); + assertFalse(execution.stderr.isEmpty(), args.toString()); + } + } + Execution unknown = execute(new CLI((directory, delegated) -> { throw new AssertionError(delegated); }), "unknown"); + assertNotEquals(0, unknown.code); + assertEquals("", unknown.stdout); + for (String command : List.of("benchmark", "assess-distribution")) { + Execution missing = execute(new CLI((directory, delegated) -> { throw new AssertionError(delegated); }), command); + assertNotEquals(0, missing.code); + assertEquals("", missing.stdout); + assertFalse(missing.stderr.isEmpty()); + Execution conflicting = execute(new CLI((directory, delegated) -> { throw new AssertionError(delegated); }), + command, "--feature=ready", "--variation", "--variable=text"); + assertNotEquals(0, conflicting.code); + assertEquals("", conflicting.stdout); + } + } + + @Test + void helpListsAliasesWithoutDelegation() throws Exception { + for (String[] args : new String[][]{{"--help"}, {"test", "--help"}, {"benchmark", "-h"}, {"assess-distribution", "--help"}}) { + Execution execution = execute(new CLI((directory, delegated) -> { throw new AssertionError(delegated); }), args); + assertEquals(0, execution.code); + for (String option : List.of("--root-directory-path", "--key-pattern", "--populate-uuid", "--show-datafile")) { + assertTrue(execution.stdout.contains(option), execution.stdout); + } + } + } + + @Test + void invalidSemverSegmentsEmitOneJsonDocumentEvenWhenVerbose(@TempDir Path project) throws Exception { + var condition = java.util.Map.of("attribute", "version", "operator", "semverEquals", "value", "1.0.0"); + var tests = json.createArrayNode(); + var segment = tests.addObject().put("segment", "stableVersion").putArray("assertions"); + var scenario = tests.addObject().put("scenario", "segment checks"); + scenario.set("_segments", json.valueToTree(java.util.Map.of("stableVersion", java.util.Map.of("conditions", condition)))); + var checks = scenario.putArray("assertions"); + for (String version : List.of("١.0.0", "1.0.0\n", "1.0.0\n2.0.0", "1.0.0-K")) { + var context = json.valueToTree(java.util.Map.of("version", version)); + segment.addObject().put("expectedToMatch", false).set("context", context); + var assertion = checks.addObject(); + assertion.set("context", context); + assertion.putArray("checks").addObject().put("segment", "stableVersion").put("matches", false); + } + for (boolean verbose : new boolean[]{false, true}) { + CLI cli = new CLI((directory, command) -> { + if (command.contains(" config ")) return "{\"environments\":false}"; + if (command.contains("--targets")) return "[]"; + if (command.contains("--segments")) return json.writeValueAsString(List.of( + java.util.Map.of("key", "stableVersion", "conditions", condition))); + if (command.contains(" build ")) return "{\"schemaVersion\":\"2\",\"revision\":\"semver\",\"segments\":{},\"features\":{}}"; + return executionProtocol(tests.toString()); + }); + List args = new ArrayList<>(List.of("test", "--json", "--projectDirectoryPath=" + project)); + if (verbose) args.add("--verbose"); + Execution execution = execute(cli, args.toArray(new String[0])); + assertEquals(0, execution.code, execution.stderr); + try (var parser = json.getFactory().createParser(execution.stdout)) { + JsonNode report = json.readTree(parser); + assertTrue(report.get("passed").asBoolean()); + assertEquals(8, report.at("/summary/assertions/passed").asInt()); + assertNull(parser.nextToken(), "stdout must contain exactly one JSON document"); + } + assertFalse(execution.stdout.contains("condition_match_error")); + } + } + + @Test + void semverErrorsRemainStructuredThroughCliReportsAndNegation(@TempDir Path project) throws Exception { + CLI cli = new CLI((directory, command) -> { + if (command.contains(" config ")) return "{\"environments\":false}"; + if (command.contains("--targets") || command.contains("--segments")) return "[]"; + if (command.contains(" build ")) return "{\"schemaVersion\":\"2\",\"revision\":\"semver\",\"segments\":{},\"features\":{}," + + "\"variables\":{\"valid\":{\"type\":\"string\",\"defaultValue\":\"1.0.0\"}," + + "\"invalid\":{\"type\":\"string\",\"defaultValue\":\"invalid\"}}}"; + return executionProtocol("[{\"scenario\":\"semver errors\",\"assertions\":[" + + "{\"checks\":[{\"not\":[{\"variable\":\"valid\",\"operator\":\"semverEquals\",\"value\":\"invalid\"}]}]}," + + "{\"checks\":[{\"not\":[{\"variable\":\"invalid\",\"operator\":\"semverEquals\",\"compareTo\":{\"variable\":\"valid\"}}]}]}," + + "{\"children\":[{\"checks\":[{\"not\":[{\"variable\":\"invalid\",\"operator\":\"semverEquals\",\"value\":\"1.0.0\"}]}]}]}]}]"); + }); + Execution execution = execute(cli, "test", "--json", "--projectDirectoryPath=" + project); + assertEquals(1, execution.code); + JsonNode cases = json.readTree(execution.stdout).at("/results/0/cases"); + assertEquals(3, cases.size()); + for (int index = 0; index < cases.size(); index++) { + assertFalse(cases.get(index).get("passed").asBoolean()); + JsonNode errors = cases.get(index).get("errors"); + assertEquals(1, errors.size()); + JsonNode error = errors.get(0); + assertEquals("check", error.get("type").asText()); + assertEquals(index == 0 ? "valid checks" : "successful comparison", error.get("expected").asText()); + assertTrue(error.get("actual").isTextual()); + assertTrue(error.get("actual").asText().contains("invalid")); + assertEquals(json.readTree(index == 2 ? "[\"children\",0,\"checks\",0]" : "[\"checks\",0]"), error.get("path")); + if (index == 2) assertEquals(0, error.at("/details/childIndex").asInt(-1)); + } + } + + @Test + void ordinaryFailuresPreserveAuthoredPathsValuesAndChildren(@TempDir Path project) throws Exception { + var feature = json.readTree("{\"expectedToBeEnabled\":false,\"expectedVariation\":\"missing\"," + + "\"expectedVariables\":{\"text\":\"wrong\"},\"expectedEvaluations\":{\"flag\":{\"enabled\":false}," + + "\"variation\":{\"featureKey\":\"wrong\"},\"variables\":{\"text\":{\"variableValue\":\"wrong\"}}}}"); + var variable = json.readTree("{\"expectedValue\":\"wrong\",\"expectedEvaluation\":{\"variableValue\":\"wrong\"}}"); + var tests = json.createArrayNode(); + for (var entry : List.of(java.util.Map.entry("feature", feature), java.util.Map.entry("variable", variable))) { + var assertion = (com.fasterxml.jackson.databind.node.ObjectNode) entry.getValue().deepCopy(); + assertion.putArray("children").add(entry.getValue()); + assertion.putObject("_case").put("assertionIndex", 7); + tests.addObject().put(entry.getKey(), entry.getKey().equals("feature") ? "flag" : "text") + .putArray("assertions").add(assertion); + } + tests.addObject().put("segment", "all").putArray("assertions").addObject() + .put("expectedToMatch", false).putObject("_case").put("assertionIndex", 7); + CLI cli = new CLI((directory, command) -> { + if (command.contains(" config ")) return "{\"environments\":false}"; + if (command.contains("--targets")) return "[]"; + if (command.contains("--segments")) return "[{\"key\":\"all\",\"conditions\":\"*\"}]"; + if (command.contains(" build ")) return "{\"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\"}}}"; + return executionProtocol(tests.toString()); + }); + Execution execution = execute(cli, "test", "--json", "--projectDirectoryPath=" + project); + assertEquals(1, execution.code); + JsonNode results = json.readTree(execution.stdout).get("results"); + JsonNode expected = json.readTree("[" + + "[[\"flag\",[\"expectedToBeEnabled\"],false,true]," + + "[\"variation\",[\"expectedVariation\"],\"missing\",null]," + + "[\"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]]]"); + assertEquals(3, results.size()); + for (int spec = 0; spec < results.size(); spec++) { + JsonNode errors = results.get(spec).at("/cases/0/errors"); + JsonNode rows = expected.get(spec); + int instances = spec == 2 ? 1 : 2; + assertFalse(results.get(spec).get("passed").asBoolean()); + assertEquals(rows.size() * instances, errors.size()); + for (int child = 0; child < instances; child++) { + for (int index = 0; index < rows.size(); index++) { + JsonNode row = rows.get(index), error = errors.get(child * rows.size() + index); + var path = json.createArrayNode().add("assertions").add(7); + if (child == 1) path.add("children").add(0); + for (JsonNode part : row.get(1)) path.add(part); + assertEquals(row.get(0), error.get("type")); + assertEquals(path, error.get("path")); + assertEquals(row.get(2), error.get("expected")); + assertEquals(row.get(3), error.get("actual")); + if (child == 1) assertEquals(0, error.at("/details/childIndex").asInt(-1)); + } + } + } + } + + @Test + void cacheIsolatesFlatAndConfiguredSelections(@TempDir Path project) throws Exception { + var targets = List.of("web", "web-target-mobile", "mobile", "Café 東京 🧪\"\\", "[null,\"web\"]"); + for (boolean flat : new boolean[] {false, true}) { + List environments = flat ? java.util.Collections.singletonList(null) + : List.of("prod", "prod-target-web", "prod-target-web-target-mobile", "false", + "__no_environment__", "Café 東京 🧪\"\\", "[null,\"web\"]"); + var selectedTargets = new ArrayList(); + selectedTargets.add(null); + selectedTargets.addAll(targets); + var assertions = json.createArrayNode(); + var builds = new java.util.LinkedHashMap(); + for (String environment : environments) { + for (String target : selectedTargets) { + String value = json.writeValueAsString(java.util.Arrays.asList(environment, target)); + assertions.addObject().put("environment", environment).put("target", target) + .put("expectedValue", value); + String command = "npx featurevisor build --json" + + (environment == null ? "" : " --environment=" + environment) + + (target == null ? "" : " --target=" + target); + builds.put(command, "{\"schemaVersion\":\"2\",\"revision\":\"cache\",\"segments\":{}," + + "\"features\":{},\"variables\":{\"selection\":{\"type\":\"string\",\"defaultValue\":" + + json.writeValueAsString(value) + "}}}"); + } + } + var calls = new ArrayList(); + CLI cli = new CLI((directory, command) -> { + if (command.contains(" config ")) return json.writeValueAsString( + java.util.Map.of("sets", false, "environments", flat ? false : environments)); + if (command.contains("--segments")) return "[]"; + if (command.contains("--targets")) return json.writeValueAsString( + targets.stream().map(target -> java.util.Map.of("key", target)).collect(java.util.stream.Collectors.toList())); + if (command.contains("--tests")) return executionProtocol( + "[{\"key\":\"selection\",\"variable\":\"selection\",\"assertions\":" + assertions + "}]"); + assertTrue(builds.containsKey(command), command); + calls.add(command); + return builds.get(command); + }); + Execution execution = execute(cli, "test", "--json", "--projectDirectoryPath=" + project); + assertEquals(0, execution.code, execution.stdout); + assertEquals(builds.size(), calls.size()); + assertEquals(builds.keySet(), new java.util.HashSet<>(calls)); + JsonNode cases = json.readTree(execution.stdout).at("/results/0/cases"); + assertEquals(assertions.size(), cases.size()); + for (int i = 0; i < assertions.size(); i++) { + assertEquals(assertions.get(i).get("environment"), cases.get(i).get("environment")); + assertEquals(assertions.get(i).get("target"), cases.get(i).get("target")); + assertTrue(cases.get(i).get("passed").asBoolean()); + } + } + } + + @Test + void rejectsExplicitSetsForNonSetsProjectsBeforeBuilding(@TempDir Path project) throws Exception { + for (String command : List.of("test", "benchmark", "assess-distribution")) { + for (String selectedSet : List.of("", "web")) { + var calls = new ArrayList(); + CLI cli = new CLI((directory, delegated) -> { + calls.add(delegated); + if (delegated.contains(" config ")) return "{\"sets\":false,\"environments\":false}"; + throw new AssertionError("Unexpected delegation: " + delegated); + }); + Execution execution = execute(cli, command, "--json", "--feature=flag", "--n=1", + "--set=" + selectedSet, "--projectDirectoryPath=" + project); + assertEquals(1, execution.code); + assertTrue((execution.stdout + execution.stderr).contains(selectedSet.isEmpty() ? "must not be empty" : "Omit --set")); + if (selectedSet.isEmpty()) assertTrue(calls.isEmpty()); + assertTrue(calls.stream().allMatch(call -> call.contains(" config "))); + } + } + } + + @Test + void jsonOutputSurvivesAnAsciiConsole(@TempDir Path project) throws Exception { + String value = "Café 東京 🧪"; + CLI cli = new CLI((directory, command) -> { + if (command.contains(" config ")) return "{\"environments\":false}"; + if (command.contains("--targets") || command.contains("--segments")) return "[]"; + if (command.contains(" build ")) return "{\"schemaVersion\":\"2\",\"revision\":\"unicode\"," + + "\"segments\":{},\"features\":{},\"variables\":{\"greeting\":{\"type\":\"string\"," + + "\"defaultValue\":" + json.writeValueAsString(value) + "}}}"; + return executionProtocol("[{\"variable\":\"greeting\",\"_test\":{\"source\":\"tests/greeting.spec.json\"}," + + "\"assertions\":[{\"description\":\"Café 東京 🧪\",\"expectedValue\":\"other\"," + + "\"_case\":{\"id\":\"unicode-case\",\"assertionIndex\":0,\"matrixIndex\":null," + + "\"matrixValues\":{}}}]}]"); + }); + + Execution machine = executeWithCharset(cli, StandardCharsets.US_ASCII, "test", "--json", + "--projectDirectoryPath=" + project); + assertEquals(1, machine.code); + assertEquals(value, json.readTree(machine.stdout) + .at("/results/0/cases/0/errors/0/actual").asText()); + assertEquals(value, json.readTree(machine.stdout) + .at("/results/0/cases/0/description").asText()); + + Execution datafile = executeWithCharset(cli, StandardCharsets.US_ASCII, "test", "--showDatafile", + "--projectDirectoryPath=" + project); + assertEquals(1, datafile.code); + assertTrue(datafile.stdout.contains("\\u00E9"), datafile.stdout); + assertTrue(datafile.stdout.contains("\\u6771"), datafile.stdout); + assertTrue(datafile.stdout.contains("\\uD83E\\uDDEA"), datafile.stdout); + } + + @Test + void emitsStructuredScenarioReportWithAuthoredPathsAndSeparateCounts(@TempDir Path project) + throws Exception { + String id = "[null,\"scenario\",\"limits.spec\",0,0]"; + List calls = new ArrayList<>(); + CLI cli = new CLI((directory, command) -> { + calls.add(command); + if (command.contains(" config ")) return "{\"environments\":false}"; + if (command.contains("--targets") || command.contains("--segments")) return "[]"; + if (command.contains("--without-tests") && command.contains("--features")) { + return "[{\"key\":\"unusedFeature\"}]"; + } + if (command.contains("--without-tests") && command.contains("--variables")) { + return "[{\"key\":\"unusedVariable\"}]"; + } + if (command.contains(" build ")) return datafileJson(); + return executionProtocol("[{\"scenario\":\"Connection limits\"," + + "\"_test\":{\"source\":\"tests/scenarios/limits.spec.json\",\"set\":null,\"entityType\":\"scenario\"}," + + "\"assertions\":[{\"description\":\"Bounds remain valid\"," + + "\"_case\":{\"id\":" + json.writeValueAsString(id) + + ",\"assertionIndex\":0,\"matrixIndex\":0,\"matrixValues\":{\"country\":\"nl\"}}," + + "\"context\":{\"country\":\"nl\",\"checks\":\"context checks\"," + + "\"custom\":\"context custom\"}," + + "\"checks\":[{\"key\":\"bounds\",\"description\":\"Minimum must not exceed maximum\"," + + "\"variable\":\"minimum\",\"operator\":\"lessThanOrEquals\"," + + "\"compareTo\":{\"variable\":\"maximum\"}}]," + + "\"children\":[{\"checks\":[{\"key\":\"child-bounds\",\"variable\":\"minimum\"," + + "\"operator\":\"lessThanOrEquals\",\"compareTo\":{\"variable\":\"maximum\"}}]}]}]}]"); + }); + + Execution execution = execute(cli, "test", "--json", "--pretty", "--verbose", "--showDatafile", + "--coverage", "--uncovered", "--projectDirectoryPath=" + project); + assertEquals(1, execution.code); + JsonNode report = json.readTree(execution.stdout); + assertEquals(1, report.get("schemaVersion").asInt()); + assertEquals("java", report.get("runner").asText()); + assertFalse(report.get("passed").asBoolean()); + assertEquals(1, report.at("/summary/specs/failed").asInt()); + assertEquals(0, report.at("/summary/entityAssertions/failed").asInt()); + assertEquals(1, report.at("/summary/scenarioCases/failed").asInt()); + assertEquals("tests/scenarios/limits.spec.json", report.at("/results/0/source").asText()); + assertEquals(id, report.at("/results/0/cases/0/id").asText()); + assertEquals("nl", report.at("/results/0/cases/0/matrixValues/country").asText()); + assertEquals("context checks", report.at("/results/0/cases/0/context/checks").asText()); + assertEquals("context custom", report.at("/results/0/cases/0/context/custom").asText()); + assertEquals(json.readTree("[\"checks\",0]").toString(), + report.at("/results/0/cases/0/errors/0/path").toString()); + assertEquals(true, report.at("/results/0/cases/0/errors/0/expected").asBoolean()); + assertEquals(false, report.at("/results/0/cases/0/errors/0/actual").asBoolean()); + assertEquals("bounds", report.at("/results/0/cases/0/errors/0/details/checkKey").asText()); + assertEquals(12, report.at("/results/0/cases/0/errors/0/details/evaluated/0/actual").asInt()); + assertEquals(10, report.at("/results/0/cases/0/errors/0/details/evaluated/0/expected").asInt()); + assertEquals(json.readTree("[\"children\",0,\"checks\",0]").toString(), + report.at("/results/0/cases/0/errors/1/path").toString()); + assertEquals("--case=" + id, report.at("/results/0/cases/0/rerun/args/1").asText()); + assertEquals("--projectDirectoryPath=" + project, + report.at("/results/0/cases/0/rerun/args/2").asText()); + assertEquals("maven-exec", report.at("/results/0/cases/0/rerun/argumentEncoding").asText()); + assertEquals("unusedFeature", report.at("/uncovered/0/features/0").asText()); + assertEquals("unusedVariable", report.at("/uncovered/0/variables/0").asText()); + assertEquals("observed-selections", report.at("/coverage/mode").asText()); + assertTrue(report.at("/coverage/selections").findValuesAsText("variableKey").contains("minimum")); + JsonNode minimumSelection = null; + for (JsonNode selection : report.at("/coverage/selections")) { + if ("minimum".equals(selection.path("variableKey").asText())) minimumSelection = selection; + } + assertNotNull(minimumSelection); + assertEquals(json.readTree("[\"netherlands\"]"), minimumSelection.get("overridePath")); + assertTrue(calls.stream().anyMatch(command -> command.contains("--apply-matrix") + && command.contains("--execution-metadata") && command.contains("--execution-protocol"))); + } + + @Test + void findsAnExactCaseAcrossSetsBeforeBuildingAndForwardsItUnchanged(@TempDir Path project) + throws Exception { + Files.createDirectories(project.resolve("sets/production")); + Files.createDirectories(project.resolve("sets/staging")); + String stagingId = "[\"staging\",\"variable\",\"minimum.spec\",\"it's exact\",null]"; + List calls = new ArrayList<>(); + CLI cli = new CLI((directory, command) -> { + calls.add(command); + if (command.contains(" config ")) return "{\"environments\":false,\"sets\":true}"; + if (command.contains("--targets") || command.contains("--segments")) return "[]"; + if (command.contains(" build ")) return datafileJson(); + if (command.contains("--set=staging")) return variableSpec(stagingId, "staging"); + return variableSpec("[\"production\",\"variable\",\"minimum.spec\",0,null]", "production"); + }); + + Execution execution = execute(cli, "test", "--json", "--case=" + stagingId, + "--projectDirectoryPath=" + project); + assertEquals(0, execution.code, execution.stdout); + JsonNode report = json.readTree(execution.stdout); + assertEquals(1, report.at("/summary/assertions/passed").asInt()); + assertEquals("staging", report.at("/results/0/set").asText()); + assertEquals(stagingId, report.at("/results/0/cases/0/id").asText()); + assertEquals("--set=staging", report.at("/results/0/cases/0/rerun/args/3").asText()); + assertTrue(calls.stream().anyMatch(command -> command.contains("--set=production") + && command.contains("--execution-metadata") && !command.contains("--case="))); + assertTrue(calls.stream().anyMatch(command -> command.contains("--set=staging") + && command.contains("--case=" + stagingId))); + assertTrue(calls.stream().filter(command -> command.contains(" build ")) + .allMatch(command -> command.contains("--set=staging"))); + } + + @Test + void runsAnExactCaseInANonSetProjectWithoutCrossSetDiscovery(@TempDir Path project) + throws Exception { + String id = "[null,\"variable\",\"minimum.spec\",\"it's exact\",null]"; + List calls = new ArrayList<>(); + CLI cli = new CLI((directory, command) -> { + calls.add(command); + if (command.contains(" config ")) return "{\"environments\":false}"; + if (command.contains("--targets") || command.contains("--segments")) return "[]"; + if (command.contains(" build ")) return datafileJson(); + return variableSpec(id, null); + }); + + Execution execution = execute(cli, "test", "--json", "--case=" + id, + "--projectDirectoryPath=" + project); + assertEquals(0, execution.code, execution.stdout); + JsonNode report = json.readTree(execution.stdout); + assertEquals(1, report.at("/summary/assertions/passed").asInt()); + assertEquals(id, report.at("/results/0/cases/0/id").asText()); + assertTrue(calls.stream().anyMatch(command -> command.contains("--case=" + id))); + assertEquals(1, calls.stream().filter(command -> command.contains("--tests")).count()); + } + + @Test + void unknownExactCaseFailsBeforeAnyDatafileBuild(@TempDir Path project) throws Exception { + List calls = new ArrayList<>(); + CLI cli = new CLI((directory, command) -> { + calls.add(command); + if (command.contains(" config ")) return "{\"environments\":false}"; + if (command.contains("--tests") && command.contains("--case=")) return executionProtocol("[]"); + if (command.contains("--tests")) return variableSpec( + "[null,\"variable\",\"minimum.spec\",0,null]", null); + throw new AssertionError("Unexpected command after failed case selection: " + command); + }); + + Execution execution = execute(cli, "test", "--json", "--case=not-found", + "--projectDirectoryPath=" + project); + assertEquals(1, execution.code); + JsonNode report = json.readTree(execution.stdout); + assertFalse(report.get("passed").asBoolean()); + assertEquals(1, report.get("errors").size()); + assertEquals(0, report.at("/summary/assertions/passed").asInt()); + assertTrue(calls.stream().noneMatch(command -> command.contains(" build "))); + } + + @Test + void humanOutputUsesCheckNamesAuthoredSourceAndQuotedRerun(@TempDir Path project) + throws Exception { + Path projectWithSpaces = Files.createDirectories(project.resolve("project with spaces")); + String id = "[null,\"scenario\",\"limits.spec\",\"it's a \\\"quoted\\\" case\",null]"; + CLI cli = new CLI((directory, command) -> { + if (command.contains(" config ")) return "{\"environments\":false}"; + if (command.contains("--targets") || command.contains("--segments")) return "[]"; + if (command.contains(" build ")) return datafileJson(); + return executionProtocol("[{\"scenario\":\"Connection limits\"," + + "\"_test\":{\"source\":\"tests/scenarios/limits.spec.json\",\"set\":null}," + + "\"assertions\":[{\"description\":\"Named failure\"," + + "\"_case\":{\"id\":" + json.writeValueAsString(id) + + ",\"assertionIndex\":0,\"matrixIndex\":null,\"matrixValues\":{}}," + + "\"checks\":[{\"key\":\"bounds\",\"description\":\"Minimum must not exceed maximum\"," + + "\"variable\":\"minimum\",\"operator\":\"lessThanOrEquals\"," + + "\"compareTo\":{\"variable\":\"maximum\"}}]}]}]"); + }); + + Execution execution = execute(cli, "test", "--projectDirectoryPath=" + projectWithSpaces); + assertEquals(1, execution.code); + assertTrue(execution.stdout.contains("Testing: tests/scenarios/limits.spec.json")); + assertTrue(execution.stdout.contains("check \"bounds\" (Minimum must not exceed maximum)")); + assertFalse(execution.stdout.contains("checks[0]"), execution.stdout); + assertTrue(execution.stdout.contains("Scenario cases: 0 passed, 1 failed")); + assertTrue(execution.stdout.contains("Total cases: 0 passed, 1 failed")); + assertTrue(execution.stdout.contains("-Dexec.args=")); + + String rerunCommand = execution.stdout.lines() + .filter(line -> line.startsWith(" Rerun (POSIX shell): ")) + .map(line -> line.substring(" Rerun (POSIX shell): ".length())) + .findFirst().orElseThrow(); + String fixtureCommand = rerunCommand.replace( + "-Dexec.mainClass=com.featurevisor.cli.CLI", + "'-Dexec.mainClass=com.featurevisor.cli.CLIReportingTest$MavenArgumentsFixture' " + + "-Dexec.classpathScope=test" + ); + Path repository = Path.of(System.getProperty("user.dir")); + if (repository.getFileName().toString().equals("featurevisor-sdk")) repository = repository.getParent(); + Process process = new ProcessBuilder("bash", "-lc", fixtureCommand) + .directory(repository.toFile()) + .start(); + int exitCode = process.waitFor(); + String fixtureStdout = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + String fixtureStderr = new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); + assertEquals(0, exitCode, fixtureStdout + fixtureStderr); + int jsonStart = fixtureStdout.indexOf("[\"test\""); + assertTrue(jsonStart >= 0, fixtureStdout); + assertEquals(json.valueToTree(List.of( + "test", "--case=" + id, "--projectDirectoryPath=" + projectWithSpaces + )), json.readTree(fixtureStdout.substring(jsonStart))); + } + + @Test + void humanChildExpectationsUseOneBasedLabelsWithoutChangingJSONPaths(@TempDir Path project) throws Exception { + CLI cli = new CLI((directory, command) -> { + if (command.contains(" config ")) return "{\"environments\":false}"; + if (command.contains("--targets") || command.contains("--segments")) return "[]"; + if (command.contains(" build ")) return datafileJson(); + return executionProtocol("[{\"variable\":\"minimum\",\"key\":\"minimum.spec\"," + + "\"_test\":{\"source\":\"tests/minimum.spec.json\"}," + + "\"assertions\":[{\"description\":\"child failure\"," + + "\"children\":[{\"expectedValue\":99,\"expectedEvaluation\":{\"variableValue\":99}}]}]}]"); + }); + + Execution human = execute(cli, "test", "--projectDirectoryPath=" + project); + assertEquals(1, human.code); + assertTrue(human.stdout.contains("Child 1, expectedValue: expected 99"), human.stdout); + assertTrue(human.stdout.contains("Child 1, expectedEvaluation.variableValue: expected 99"), human.stdout); + assertFalse(human.stdout.contains("children[0]"), human.stdout); + + Execution machine = execute(cli, "test", "--json", "--projectDirectoryPath=" + project); + JsonNode errors = json.readTree(machine.stdout).at("/results/0/cases/0/errors"); + assertEquals(json.readTree("[\"assertions\",0,\"children\",0,\"expectedValue\"]"), errors.get(0).get("path")); + assertEquals(json.readTree("[\"assertions\",0,\"children\",0,\"expectedEvaluation\",\"variableValue\"]"), errors.get(1).get("path")); + } + + @Test + void entityErrorsUseTheAuthoredAssertionIndexAndExplicitMissingEvidence(@TempDir Path project) + throws Exception { + String id = "[null,\"variable\",\"settings.spec\",\"missing-path\",null]"; + CLI cli = new CLI((directory, command) -> { + if (command.contains(" config ")) return "{\"environments\":false}"; + if (command.contains("--targets") || command.contains("--segments")) return "[]"; + if (command.contains(" build ")) return datafileJson(); + return executionProtocol("[{\"variable\":\"settings\",\"_test\":{\"source\":\"tests/settings.spec.json\"," + + "\"set\":null},\"assertions\":[{\"description\":\"Missing is not null\"," + + "\"_case\":{\"id\":" + json.writeValueAsString(id) + + ",\"assertionIndex\":4,\"matrixIndex\":null,\"matrixValues\":{}}," + + "\"checks\":[{\"key\":\"missing-value\",\"variable\":\"settings\"," + + "\"path\":\"absent\",\"operator\":\"isNull\"}]}]}]"); + }); + + Execution execution = execute(cli, "test", "--json", "--projectDirectoryPath=" + project); + assertEquals(1, execution.code); + JsonNode error = json.readTree(execution.stdout).at("/results/0/cases/0/errors/0"); + assertEquals(json.readTree("[\"assertions\",4,\"checks\",0]"), error.get("path")); + assertEquals(json.readTree("[\"assertions\",4,\"checks\",0]"), + error.at("/details/evaluated/0/path")); + assertTrue(error.at("/details/evaluated/0/actualMissing").asBoolean()); + assertEquals("undefined", error.at("/details/evaluated/0/actual/$type").asText()); + } + + @Test + void targetFiltersSelectTargetedAndUntargetedCasesAndRemainInReruns(@TempDir Path project) + throws Exception { + String mobileId = "[null,\"variable\",\"minimum.spec\",\"mobile\",null]"; + List calls = new ArrayList<>(); + CLI cli = new CLI((directory, command) -> { + calls.add(command); + if (command.contains(" config ")) return "{\"environments\":false}"; + if (command.contains("--targets")) return "[{\"key\":\"web\"},{\"key\":\"mobile\"}]"; + if (command.contains("--segments")) return "[]"; + if (command.contains(" build ")) return datafileJson(); + return targetedVariableSpec(); + }); + + Execution execution = execute(cli, "test", "--json", "--target=web", + "--projectDirectoryPath=" + project); + assertEquals(0, execution.code); + JsonNode report = json.readTree(execution.stdout); + assertEquals(2, report.at("/summary/assertions/passed").asInt()); + assertEquals("web", report.at("/results/0/cases/0/target").asText()); + assertEquals("--target=web", report.at("/results/0/cases/0/rerun/args/3").asText()); + assertTrue(calls.stream().anyMatch(command -> command.contains(" build ") && command.contains("--target=web"))); + assertTrue(calls.stream().noneMatch(command -> command.contains(" build ") && command.contains("--target=mobile"))); + + List rejectedCalls = new ArrayList<>(); + CLI rejected = new CLI((directory, command) -> { + rejectedCalls.add(command); + if (command.contains(" config ")) return "{\"environments\":false}"; + if (command.contains("--tests") && command.contains("--case=")) { + return executionProtocol("[{\"variable\":\"minimum\",\"_test\":{\"source\":\"tests/minimum.spec.json\"," + + "\"set\":null},\"assertions\":[" + targetedAssertion("mobile", "mobile", 1) + "]}]"); + } + if (command.contains("--tests")) return targetedVariableSpec(); + throw new AssertionError("Excluded exact case must fail before builds: " + command); + }); + Execution rejectedExecution = execute(rejected, "test", "--json", "--target=web", + "--case=" + mobileId, "--projectDirectoryPath=" + project); + assertEquals(1, rejectedExecution.code); + assertEquals(0, json.readTree(rejectedExecution.stdout).at("/summary/assertions/passed").asInt()); + assertTrue(rejectedCalls.stream().noneMatch(command -> command.contains(" build "))); + } + + @Test + void rejectsInvalidExecutionProtocolBeforeBuilds(@TempDir Path project) throws Exception { + List invalid = List.of( + "[]", + "{}", + "{\"kind\":null,\"schemaVersion\":1,\"tests\":[]}", + "{\"kind\":\"wrong\",\"schemaVersion\":1,\"tests\":[]}", + "{\"kind\":\"featurevisor.test-execution\",\"tests\":[]}", + "{\"kind\":\"featurevisor.test-execution\",\"schemaVersion\":null,\"tests\":[]}", + "{\"kind\":\"featurevisor.test-execution\",\"schemaVersion\":true,\"tests\":[]}", + "{\"kind\":\"featurevisor.test-execution\",\"schemaVersion\":\"1\",\"tests\":[]}", + "{\"kind\":\"featurevisor.test-execution\",\"schemaVersion\":2,\"tests\":[]}", + "{\"kind\":\"featurevisor.test-execution\",\"schemaVersion\":1,\"tests\":null}", + "{\"kind\":\"featurevisor.test-execution\",\"schemaVersion\":1,\"tests\":[1]}" + ); + for (String document : invalid) { + List calls = new ArrayList<>(); + CLI cli = new CLI((directory, command) -> { + calls.add(command); + if (command.contains(" config ")) return "{\"environments\":false}"; + if (command.contains("--tests")) return document; + throw new AssertionError("Protocol rejection must stop before " + command); + }); + Execution execution = execute(cli, "test", "--json", + "--projectDirectoryPath=" + project); + assertEquals(1, execution.code, document); + assertTrue(calls.stream().noneMatch(command -> command.contains(" build ")), document); + } + } + + @Test + void preflightChecksChildrenAndSelectedTargetsBeforeEvaluation(@TempDir Path project) + throws Exception { + CLI cli = new CLI((directory, command) -> { + if (command.contains(" config ")) return "{\"environments\":false}"; + if (command.contains("--segments")) return "[]"; + if (command.contains("--targets")) return "[{\"key\":\"mobile\"}]"; + if (command.contains(" build ")) { + boolean target = command.contains("--target=mobile"); + return "{\"schemaVersion\":\"2\",\"revision\":\"" + + (target ? "target" : "base") + + "\",\"segments\":{},\"features\":" + + (target ? "{}" : "{\"baseOnly\":{\"bucketBy\":[\"userId\"],\"traffic\":[]}}") + + ",\"variables\":{\"minimum\":{\"type\":\"integer\",\"defaultValue\":5}}}"; + } + return executionProtocol("[" + + "{\"variable\":\"minimum\",\"assertions\":[{\"expectedValue\":5," + + "\"checks\":[{\"operator\":\"equals\",\"value\":5}]," + + "\"children\":[{\"checks\":[{\"or\":[" + + "{\"operator\":\"equals\",\"value\":5}," + + "{\"variable\":\"missing\",\"operator\":\"equals\",\"value\":0}]}]}]}]}," + + "{\"scenario\":\"selected target\",\"assertions\":[{\"target\":\"mobile\"," + + "\"checks\":[{\"feature\":\"baseOnly\",\"enabled\":true}]}]}]"); + }); + + Execution execution = execute(cli, "test", "--json", "--coverage", + "--projectDirectoryPath=" + project); + assertEquals(1, execution.code); + JsonNode report = json.readTree(execution.stdout); + assertEquals(2, report.at("/summary/assertions/failed").asInt()); + assertEquals(0, report.at("/coverage/selections").size()); + } + + private String variableSpec(String id, String set) throws IOException { + return executionProtocol("[{\"variable\":\"minimum\",\"_test\":{\"source\":\"" + + (set == null ? "tests/minimum.spec.json" : "sets/" + set + "/tests/minimum.spec.json") + + "\",\"set\":" + json.writeValueAsString(set) + ",\"entityType\":\"variable\"}," + + "\"assertions\":[{\"description\":\"Minimum\",\"expectedValue\":12," + + "\"_case\":{\"id\":" + json.writeValueAsString(id) + + ",\"assertionIndex\":0,\"matrixIndex\":null,\"matrixValues\":{}}}]}]"); + } + + private String targetedVariableSpec() throws IOException { + return executionProtocol("[{\"variable\":\"minimum\",\"_test\":{\"source\":\"tests/minimum.spec.json\"," + + "\"set\":null},\"assertions\":[" + + targetedAssertion("web", "web", 0) + "," + + targetedAssertion("mobile", "mobile", 1) + "," + + targetedAssertion("base", null, 2) + "]}]"); + } + + private String targetedAssertion(String key, String target, int index) throws IOException { + String id = "[null,\"variable\",\"minimum.spec\",\"" + key + "\",null]"; + return "{\"description\":\"" + key + "\",\"expectedValue\":12" + + (target == null ? "" : ",\"target\":\"" + target + "\"") + + ",\"_case\":{\"id\":" + json.writeValueAsString(id) + ",\"assertionIndex\":" + index + + ",\"matrixIndex\":null,\"matrixValues\":{}}}"; + } + + private String datafileJson() { + return "{\"schemaVersion\":\"2\",\"revision\":\"report\",\"segments\":{},\"features\":{}," + + "\"variables\":{\"minimum\":{\"type\":\"integer\",\"defaultValue\":5," + + "\"overrides\":[{\"key\":\"nl\",\"keyPath\":[\"netherlands\"],\"segments\":\"*\",\"value\":12}]}," + + "\"maximum\":{\"type\":\"integer\",\"defaultValue\":10}," + + "\"settings\":{\"type\":\"object\",\"defaultValue\":{\"nullable\":null}}}}"; + } + + private String executionProtocol(String tests) { + return "{\"kind\":\"featurevisor.test-execution\",\"schemaVersion\":1,\"tests\":" + + tests + "}"; + } + + private Execution execute(CLI cli, String... args) throws Exception { + return executeWithCharset(cli, StandardCharsets.UTF_8, args); + } + + private Execution executeWithCharset(CLI cli, Charset charset, String... args) throws Exception { + PrintStream originalOut = System.out; + PrintStream originalErr = System.err; + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + try { + System.setOut(new PrintStream(stdout, true, charset.name())); + System.setErr(new PrintStream(stderr, true, charset.name())); + int code = new CommandLine(cli).execute(args); + return new Execution(code, stdout.toString(StandardCharsets.UTF_8.name()), + stderr.toString(StandardCharsets.UTF_8.name())); + } finally { + System.setOut(originalOut); + System.setErr(originalErr); + } + } + + private static final class Execution { + final int code; + final String stdout; + final String stderr; + + Execution(int code, String stdout, String stderr) { + this.code = code; + this.stdout = stdout; + this.stderr = stderr; + } + } + + public static final class MavenArgumentsFixture { + private MavenArgumentsFixture() {} + + public static void main(String[] args) throws Exception { + System.out.print(new ObjectMapper().writeValueAsString(args)); + } + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/cli/IncludesAnyCheckTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/cli/IncludesAnyCheckTest.java new file mode 100644 index 0000000..005ddca --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/cli/IncludesAnyCheckTest.java @@ -0,0 +1,57 @@ +package com.featurevisor.cli; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class IncludesAnyCheckTest { + @Test + void literalCompareToAndStructuredErrors() throws Exception { + List> rows = new ObjectMapper().readValue( + "[{\"name\":\"mixed\",\"actual\":[\"red\",1,true,null],\"candidates\":[\"absent\",1],\"valid\":true,\"found\":true},{\"name\":\"null\",\"actual\":[null],\"candidates\":[null],\"valid\":true,\"found\":true},{\"name\":\"boolean\",\"actual\":[false],\"candidates\":[false],\"valid\":true,\"found\":true},{\"name\":\"number not bool\",\"actual\":[1],\"candidates\":[true],\"valid\":true,\"found\":false},{\"name\":\"bool not number\",\"actual\":[true],\"candidates\":[1],\"valid\":true,\"found\":false},{\"name\":\"string not number\",\"actual\":[\"1\"],\"candidates\":[1],\"valid\":true,\"found\":false},{\"name\":\"string not bool\",\"actual\":[\"true\"],\"candidates\":[true],\"valid\":true,\"found\":false},{\"name\":\"duplicates\",\"actual\":[\"red\",\"red\"],\"candidates\":[\"blue\",\"red\",\"red\"],\"valid\":true,\"found\":true},{\"name\":\"empty actual\",\"actual\":[],\"candidates\":[null],\"valid\":true,\"found\":false},{\"name\":\"empty candidate\",\"actual\":[1],\"candidates\":[],\"valid\":true,\"found\":false},{\"name\":\"both empty\",\"actual\":[],\"candidates\":[],\"valid\":true,\"found\":false},{\"name\":\"structured actual\",\"actual\":[{},[1],\"red\"],\"candidates\":[\"red\"],\"valid\":true,\"found\":true},{\"name\":\"structured ignored\",\"actual\":[{},[1]],\"candidates\":[null,1],\"valid\":true,\"found\":false},{\"name\":\"object after match\",\"actual\":[1],\"candidates\":[1,{}],\"valid\":false,\"found\":false},{\"name\":\"array after match\",\"actual\":[1],\"candidates\":[1,[]],\"valid\":false,\"found\":false},{\"name\":\"object first\",\"actual\":[1],\"candidates\":[{},1],\"valid\":false,\"found\":false},{\"name\":\"null actual\",\"actual\":null,\"candidates\":[],\"valid\":false,\"found\":false},{\"name\":\"object actual\",\"actual\":{},\"candidates\":[],\"valid\":false,\"found\":false},{\"name\":\"scalar actual\",\"actual\":\"red\",\"candidates\":[\"red\"],\"valid\":false,\"found\":false},{\"name\":\"null candidates\",\"actual\":[],\"candidates\":null,\"valid\":false,\"found\":false},{\"name\":\"scalar candidates\",\"actual\":[\"red\"],\"candidates\":\"red\",\"valid\":false,\"found\":false},{\"name\":\"object candidates\",\"actual\":[],\"candidates\":{},\"valid\":false,\"found\":false}]", new TypeReference>>() {}); + for (Map row : rows) { + for (String op : List.of("includesAny", "notIncludesAny")) { + for (boolean compareTo : new boolean[]{false, true}) { + Map actual = new HashMap<>(); + actual.put("items", row.get("actual")); + VariableChecks checker = new VariableChecks(reference -> VariableChecks.Resolution.value( + "subject".equals(reference.get("variable")) ? actual : row.get("candidates"))); + Map leaf = new HashMap<>(Map.of("variable", "subject", "path", "items", "operator", op)); + if (compareTo) leaf.put("compareTo", Map.of("variable", "other")); + else leaf.put("value", row.get("candidates")); + boolean valid = (boolean) row.get("valid"); + boolean want = valid && ((boolean) row.get("found") == op.equals("includesAny")); + String label = row.get("name") + " " + op + " compareTo=" + compareTo; + List result = checker.evaluateStructured(List.of(leaf)); + assertEquals(want, result.isEmpty(), label); + assertEquals(!valid, result.stream().anyMatch(error -> error.message.contains(" error:")), label); + if (!valid) { + assertFalse(result.get(0).path.isEmpty(), label); + for (String group : List.of("not", "or")) { + List grouped = checker.evaluateStructured(List.of(Map.of(group, List.of(leaf)))); + assertFalse(grouped.isEmpty(), label + group); + assertTrue(grouped.get(0).message.contains(" error:"), label + group); + } + } + } + } + } + } + + @Test + void missingPathsAndSkippedOperands() { + VariableChecks checker = new VariableChecks(reference -> VariableChecks.Resolution.value(Map.of())); + for (String op : List.of("includesAny", "notIncludesAny")) { + List missing = checker.evaluateStructured(List.of( + Map.of("variable", "subject", "path", "missing", "operator", op, "value", List.of()))); + assertEquals(1, missing.size()); + assertFalse(missing.get(0).message.contains(" error:")); + List skipped = checker.evaluateStructured(List.of(Map.of("or", List.of( + Map.of("variable", "subject", "operator", "exists"), + Map.of("variable", "subject", "operator", op, "compareTo", Map.of("variable", "other")))))); + assertTrue(skipped.isEmpty(), skipped.toString()); + } + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/cli/NativeV4SegmentsTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/cli/NativeV4SegmentsTest.java new file mode 100644 index 0000000..79130a5 --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/cli/NativeV4SegmentsTest.java @@ -0,0 +1,106 @@ +package com.featurevisor.cli; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import picocli.CommandLine; +import static org.junit.jupiter.api.Assertions.*; + +final class NativeV4SegmentsTest { + private final ObjectMapper json = new ObjectMapper(); + private JsonNode run(Path project, String config, String datafile, String tests, int expectedCode, String... options) throws Exception { + CLI cli = new CLI((directory, command) -> { + if (command.contains(" config ")) return config; + if (command.contains("--targets")) return "[{\"key\":\"thin\"}]"; + if (command.contains("--segments")) return "[]"; + if (command.contains(" build ")) return datafile; + if (!command.contains("--execution-protocol")) throw new AssertionError(command); + return "{\"kind\":\"featurevisor.test-execution\",\"schemaVersion\":1,\"tests\":" + tests + "}"; + }); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + ByteArrayOutputStream errors = new ByteArrayOutputStream(); + PrintStream oldOut = System.out, oldErr = System.err; + try (PrintStream stdout = new PrintStream(output, true, StandardCharsets.UTF_8); + PrintStream stderr = new PrintStream(errors, true, StandardCharsets.UTF_8)) { + System.setOut(stdout); System.setErr(stderr); + List args = new ArrayList<>(List.of("test", "--json", "--coverage", "--project-directory-path=" + project)); + args.addAll(List.of(options)); + int code = new CommandLine(cli).execute(args.toArray(new String[0])); + assertEquals(expectedCode, code, output + "\n" + errors); + } finally { System.setOut(oldOut); System.setErr(oldErr); } + return json.readTree(output.toString(StandardCharsets.UTF_8)); + } + private static final String DATAFILE = """ + {"schemaVersion":"4","revision":"segments","segments":{},"features":{ + "gate":{"bucketBy":"userId","traffic":[{"key":"all","conditions":"*","percentage":50000}]} + },"variables":{"source":{"type":"object","defaultValue":{"items":[false]}}}} + """; + + @Test void ownerAndChecksUseSdkGettersCompiledPathsAtAndChildStickyState(@TempDir Path project) throws Exception { + JsonNode report = run(project, "{\"environments\":false}", DATAFILE, """ + [{"segment":"dependent","_segments":{ + "dependent":{"conditions":["inner",{"feature":"gate"}]}, + "inner":{"conditions":{"variable":"source","path":["items",0],"operator":"equals","value":true}} + },"assertions":[ + {"at":10,"stickyVariables":{"source":{"items":[true]}},"expectedToMatch":true}, + {"at":90,"stickyVariables":{"source":{"items":[true]}},"expectedToMatch":false}, + {"at":10,"expectedToMatch":false} + ]},{"scenario":"checks","_segments":{ + "dependent":{"conditions":["inner",{"feature":"gate"}]}, + "inner":{"conditions":{"variable":"source","path":["items",0],"operator":"equals","value":true}} + },"assertions":[{"at":10,"stickyVariables":{"source":{"items":[true]}}, + "checks":[{"segment":"dependent","matches":true}],"children":[ + {"stickyVariables":{"source":{"items":[true]}},"checks":[{"segment":"dependent","matches":true}]}, + {"checks":[{"segment":"dependent","matches":false}]}]}]}] + """, 0); + assertTrue(report.path("passed").asBoolean()); + for (JsonNode selection : report.path("coverage").path("selections")) { + assertFalse(selection.path("featureKey").asText().startsWith("__featurevisor_segment_test__")); + } + } + + @Test void selectedTargetSpecializationRemainsAuthoritativeForOwnersAndChecks(@TempDir Path project) throws Exception { + String selected = DATAFILE.replace("\"segments\":{}", "\"segments\":{\"dutch\":{\"conditions\":\"*\"}}"); + JsonNode report = run(project, "{\"environments\":false}", selected, """ + [{"segment":"dutch","_segments":{"dutch":{"conditions":{"attribute":"country","operator":"equals","value":"nl"}}}, + "assertions":[{"target":"thin","context":{"country":"us"},"expectedToMatch":true}]}, + {"scenario":"specialized","_segments":{"dutch":{"conditions":{"attribute":"country","operator":"equals","value":"nl"}}}, + "assertions":[{"target":"thin","context":{"country":"us"},"checks":[{"segment":"dutch","matches":true}]}]}] + """, 0, "--target=thin"); + assertTrue(report.path("passed").asBoolean()); + } + + @Test void attributeChainsNeedNoEnvironmentButDependentOwnersDo(@TempDir Path project) throws Exception { + String config = "{\"environments\":[\"staging\"]}"; + JsonNode report = run(project, config, DATAFILE, """ + [{"segment":"outer","_segments":{"outer":{"conditions":"inner"},"inner":{"conditions":"*"}}, + "assertions":[{"expectedToMatch":true}]}] + """, 0); + assertTrue(report.path("passed").asBoolean()); + report = run(project, config, DATAFILE, """ + [{"segment":"dependent","_segments":{"dependent":{"conditions":{"feature":"gate"}}}, + "assertions":[{"expectedToMatch":false}]}] + """, 1); + assertFalse(report.path("passed").asBoolean()); + assertTrue(report.toString().contains("selected datafile")); + } + + @Test void preflightVisitsSkippedTransitiveBranchesBeforeAnyOwnerGetter(@TempDir Path project) throws Exception { + JsonNode report = run(project, "{\"environments\":false}", DATAFILE, """ + [{"feature":"gate","_segments":{"outer":{"conditions":{"or":["*","inner"]}}, + "inner":{"conditions":{"variable":"missing","operator":"notNull"}}}, + "assertions":[{"at":10,"expectedToBeEnabled":true,"checks":[ + {"or":[{"feature":"gate","enabled":true},{"segment":"outer","matches":true}]}]}]}] + """, 1); + assertFalse(report.path("passed").asBoolean()); + assertTrue(report.toString().contains("missing")); + assertEquals(0, report.path("coverage").path("selections").size()); + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/cli/RuntimeCommandTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/cli/RuntimeCommandTest.java new file mode 100644 index 0000000..20e2685 --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/cli/RuntimeCommandTest.java @@ -0,0 +1,187 @@ +package com.featurevisor.cli; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import picocli.CommandLine; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class RuntimeCommandTest { + private static final String DATAFILE = "{\"schemaVersion\":\"2\",\"revision\":\"1\",\"segments\":{}," + + "\"features\":{\"checkout\":{\"bucketBy\":\"userId\",\"traffic\":[{\"key\":\"all\",\"segments\":\"*\",\"percentage\":100000}]}}," + + "\"variables\":{\"colour\":{\"type\":\"string\",\"defaultValue\":\"blue\"}}}"; + + @Test + void inflateValidationAndForwarding() { + CLI.NodeCommand unexpected = (directory, arguments) -> { fail("Invalid inflate must fail before delegation"); return DATAFILE; }; + for (String command : List.of("benchmark", "assess-distribution", "test")) { + for (String value : List.of("0", "-1")) { + assertEquals(1, new CommandLine(new CLI(unexpected)).execute(command, "--feature=checkout", "--n=1", "--inflate=" + value)); + } + for (String value : List.of("0.5", "1.5", "invalid", "", "999999999999999999999999999999")) { + assertEquals(2, new CommandLine(new CLI(unexpected)).execute(command, "--feature=checkout", "--n=1", "--inflate=" + value)); + } + assertEquals(2, new CommandLine(new CLI(unexpected)).execute(command, "--inflate")); + for (boolean supplied : List.of(false, true)) { + List builds = new ArrayList<>(); + CLI.NodeCommand node = (directory, arguments) -> { + if (arguments.contains("config --json")) return "{}"; + if (arguments.contains("list --tests")) return "{\"kind\":\"featurevisor.test-execution\",\"schemaVersion\":1,\"tests\":[]}"; + if (arguments.contains("list --segments") || arguments.contains("list --targets")) return "[]"; + assertTrue(arguments.contains("build --json"), arguments); + builds.add(arguments); + return DATAFILE; + }; + List args = new ArrayList<>(List.of(command, "--feature=checkout", "--n=1")); + if (supplied) args.add("--inflate=1"); + assertEquals(0, new CommandLine(new CLI(node)).execute(args.toArray(new String[0]))); + assertEquals(List.of(supplied ? "npx featurevisor build --json --inflate=1" : "npx featurevisor build --json"), builds); + } + } + } + + @Test + void delegatesFlatEnvironmentSetAndDistinctTargets() { + for (String command : List.of("benchmark", "assess-distribution")) { + List calls = new ArrayList<>(); + CLI.NodeCommand node = (directory, arguments) -> { + if (arguments.contains("config --json")) return "{}"; + calls.add(arguments); return DATAFILE; + }; + assertEquals(0, new CommandLine(new CLI(node)).execute(command, "--feature=checkout", "--n=1")); + assertEquals(List.of("npx featurevisor build --json"), calls); + calls.clear(); + assertEquals(0, new CommandLine(new CLI(node)).execute(command, "--feature=checkout", "--n=1", + "--environment=production", "--set=storefront", "--target=web", "--target=mobile", "--target=web")); + assertEquals(List.of( + "npx featurevisor build --json --environment=production --target=web --set=storefront", + "npx featurevisor build --json --environment=production --target=mobile --set=storefront"), calls); + } + assertEquals(0, new CommandLine(new CLI((directory, arguments) -> arguments.contains("config --json") ? "{}" : DATAFILE)) + .execute("benchmark", "--variable=colour", "--n=1")); + } + + @Test + void delegatedFailureAndMalformedDatafileReturnFailure() { + for (String command : List.of("benchmark", "assess-distribution")) { + for (String selection : List.of("--environment=unknown", "--set=unknown", "--target=unknown")) { + assertEquals(1, new CommandLine(new CLI((directory, arguments) -> { + if (arguments.contains("config --json")) return "{}"; + assertTrue(arguments.contains(selection)); + throw new IOException("Invalid project selection"); + })).execute(command, "--feature=checkout", "--n=1", selection)); + } + assertEquals(1, new CommandLine(new CLI((directory, arguments) -> arguments.contains("config --json") ? "{}" : "not JSON")) + .execute(command, "--feature=checkout", "--n=1")); + assertEquals(1, new CommandLine(new CLI((directory, arguments) -> { + assertEquals("npx featurevisor config --json", arguments); + return "not JSON"; + })).execute(command, "--feature=checkout", "--n=1")); + List calls = new ArrayList<>(); + assertEquals(1, new CommandLine(new CLI((directory, arguments) -> { + if (arguments.contains("config --json")) return "{}"; + calls.add(arguments); + if (arguments.contains("--target=unknown")) throw new IOException("Unknown target"); + return DATAFILE; + })).execute(command, "--feature=checkout", "--n=1", "--target=web", "--target=unknown", "--target=unreached")); + assertEquals(2, calls.size()); + assertFalse(calls.toString().contains("unreached")); + } + } + + @Test + void invalidOptionsFailBeforeDelegation() { + CLI.NodeCommand unexpected = (directory, arguments) -> { fail("Unexpected delegation"); return DATAFILE; }; + for (String command : List.of("benchmark", "assess-distribution")) { + assertEquals(1, new CommandLine(new CLI(unexpected)).execute(command)); + for (String n : List.of("0", "-1")) { + assertEquals(1, new CommandLine(new CLI(unexpected)).execute(command, "--feature=checkout", "--n=" + n)); + } + for (String n : List.of("invalid", "1.5")) { + assertEquals(2, new CommandLine(new CLI(unexpected)).execute(command, "--feature=checkout", "--n=" + n)); + } + } + assertEquals(1, new CommandLine(new CLI(unexpected)).execute("benchmark", "--variable=colour", "--variation")); + assertEquals(1, new CommandLine(new CLI(unexpected)).execute("benchmark", "--feature=checkout", "--variable=colour", "--variation")); + } + + @Test + void allSetsAndExplicitSetsWithNestedTargets(@TempDir Path project) throws Exception { + Path sets = project.resolve("lanes with spaces"); + Files.createDirectories(sets.resolve("store front")); + Files.createDirectories(sets.resolve("admin")); + Files.writeString(sets.resolve("ignored.txt"), "not a set"); + List calls = new ArrayList<>(); + CLI.NodeCommand node = (directory, arguments) -> { + calls.add(arguments); + if (arguments.contains("config --json")) return "{\"sets\":true,\"setsDirectoryPath\":\"lanes with spaces\"}"; + if (arguments.contains("--set=unknown") || arguments.contains("--target=unknown")) throw new IOException("Invalid project selection"); + return DATAFILE; + }; + for (String command : List.of("benchmark", "assess-distribution")) { + List base = List.of(command, "--projectDirectoryPath=" + project, "--feature=checkout", "--n=1"); + calls.clear(); + assertEquals(0, new CommandLine(new CLI(node)).execute(base.toArray(new String[0]))); + assertEquals(List.of("npx featurevisor config --json", "npx featurevisor build --json --set=admin", + "npx featurevisor build --json --set=store front"), calls); + + List targets = new ArrayList<>(base); + targets.addAll(List.of("--environment=production", "--target=nested.web", "--target=mobile", "--target=nested.web")); + calls.clear(); + CLI cli = new CLI(node); + assertEquals(0, new CommandLine(cli).execute(targets.toArray(new String[0]))); + List expected = new ArrayList<>(List.of("npx featurevisor config --json")); + for (String set : List.of("admin", "store front")) { + for (String target : List.of("nested.web", "mobile")) { + expected.add("npx featurevisor build --json --environment=production --target=" + target + " --set=" + set); + } + } + assertEquals(expected, calls); + calls.clear(); + assertEquals(0, cli.call()); + assertEquals(expected, calls, "Selections must be restored after execution"); + + targets.add("--set=store front"); + calls.clear(); + assertEquals(0, new CommandLine(new CLI(node)).execute(targets.toArray(new String[0]))); + List explicitExpected = new ArrayList<>(List.of("npx featurevisor config --json")); + explicitExpected.addAll(expected.subList(3, 5)); + assertEquals(explicitExpected, calls); + List explicit = new ArrayList<>(base); + explicit.add("--set=store front"); + calls.clear(); + assertEquals(0, new CommandLine(new CLI(node)).execute(explicit.toArray(new String[0]))); + assertEquals(List.of("npx featurevisor config --json", "npx featurevisor build --json --set=store front"), calls); + explicit.set(explicit.size() - 1, "--set=unknown"); + calls.clear(); + assertEquals(1, new CommandLine(new CLI(node)).execute(explicit.toArray(new String[0]))); + assertEquals(List.of("npx featurevisor config --json", "npx featurevisor build --json --set=unknown"), calls); + + List invalidTargets = new ArrayList<>(base); + invalidTargets.addAll(List.of("--target=unknown", "--target=unreached")); + calls.clear(); + assertEquals(1, new CommandLine(new CLI(node)).execute(invalidTargets.toArray(new String[0]))); + assertEquals(List.of("npx featurevisor config --json", "npx featurevisor build --json --target=unknown --set=admin"), calls); + } + Files.delete(sets.resolve("store front")); + Files.delete(sets.resolve("admin")); + for (String command : List.of("benchmark", "assess-distribution")) { + calls.clear(); + assertEquals(1, new CommandLine(new CLI(node)).execute(command, "--projectDirectoryPath=" + project, "--feature=checkout")); + assertEquals(List.of("npx featurevisor config --json"), calls); + } + Files.delete(sets.resolve("ignored.txt")); + Files.delete(sets); + for (String command : List.of("benchmark", "assess-distribution")) { + calls.clear(); + assertEquals(1, new CommandLine(new CLI(node)).execute(command, "--projectDirectoryPath=" + project, "--feature=checkout")); + assertEquals(List.of("npx featurevisor config --json"), calls); + } + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/cli/SemverRuntimeConformanceTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/cli/SemverRuntimeConformanceTest.java new file mode 100644 index 0000000..e6f046b --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/cli/SemverRuntimeConformanceTest.java @@ -0,0 +1,99 @@ +package com.featurevisor.cli; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.featurevisor.sdk.DatafileContent; +import com.featurevisor.sdk.Featurevisor; +import com.featurevisor.sdk.FeaturevisorLogLevel; +import java.io.InputStream; +import java.util.*; +import org.junit.jupiter.api.Test; + +final class SemverRuntimeConformanceTest { + @Test + void portableConditionsRequirementsAndChecks() throws Exception { + ObjectMapper json = new ObjectMapper(); + JsonNode fixture; + try (InputStream stream = getClass().getResourceAsStream("/conformance/semver.json")) { + assertNotNull(stream); + fixture = json.readTree(stream); + } + assertEquals("featurevisor.semver", fixture.get("kind").asText()); + assertEquals(1, fixture.get("version").asInt()); + assertEquals(44, fixture.get("cases").size()); + List operators = List.of("semverEquals", "semverNotEquals", "semverGreaterThan", + "semverGreaterThanOrEquals", "semverLessThan", "semverLessThanOrEquals"); + for (JsonNode entry : fixture.get("cases")) { + for (boolean reverse : new boolean[]{false, true}) { + String a = entry.get(reverse ? "right" : "left").asText(); + String b = entry.get(reverse ? "left" : "right").asText(); + boolean valid = !entry.get("order").isNull(); + int order = entry.get("order").asInt() * (reverse ? -1 : 1); + boolean[] outcomes = {order == 0, order != 0, order > 0, order >= 0, order < 0, order <= 0}; + for (int index = 0; index < operators.size(); index++) { + String operator = operators.get(index); + String label = entry.get("name").asText() + " reverse=" + reverse + " " + operator; + boolean expected = valid && outcomes[index]; + Map requirement = Map.of("variable", "source", "operator", operator, "value", b); + Map traffic = Map.of("key", "all", "segments", "*", "percentage", 100000, "allocation", List.of()); + Map conditionTraffic = new LinkedHashMap<>(traffic); + conditionTraffic.put("segments", List.of("version")); + Map variables = new LinkedHashMap<>(); + variables.put("source", Map.of("type", "string", "defaultValue", a)); + variables.put("other", Map.of("type", "string", "defaultValue", b)); + for (boolean featureSource : new boolean[]{false, true}) { + Map selected = new LinkedHashMap<>(requirement); + if (featureSource) selected.put("feature", "sourceFeature"); + for (boolean negated : new boolean[]{false, true}) { + variables.put("subject" + featureSource + negated, Map.of("type", "boolean", + "defaultValue", true, "disabledValue", false, + "conditions", List.of(negated ? Map.of("not", List.of(selected)) : selected))); + } + } + DatafileContent datafile = json.convertValue(Map.of( + "schemaVersion", "2", "revision", "semver", + "segments", Map.of("version", Map.of("conditions", Map.of("attribute", "version", "operator", operator, "value", b))), + "features", Map.of( + "condition", Map.of("bucketBy", "userId", "traffic", List.of(conditionTraffic)), + "sourceFeature", Map.of("bucketBy", "userId", "traffic", List.of(traffic), + "variablesSchema", Map.of("source", Map.of("type", "string", "defaultValue", a)))), + "variables", variables), DatafileContent.class); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(datafile).logLevel(FeaturevisorLogLevel.FATAL)); + try { + assertEquals(expected, f.isEnabled("condition", Map.of("version", a)), label); + VariableChecks evaluator = new VariableChecks(reference -> VariableChecks.Resolution.value( + reference.containsKey("feature") + ? f.getVariable((String) reference.get("feature"), (String) reference.get("variable")) + : f.getVariable((String) reference.get("variable")))); + for (boolean featureSource : new boolean[]{false, true}) { + for (boolean negated : new boolean[]{false, true}) { + String subject = "subject" + featureSource + negated; + assertEquals(valid ? expected != negated : null, f.getVariable(subject), label); + if (!valid) assertEquals("error", f.evaluateVariable(subject).getReason(), label); + } + for (boolean compareTo : new boolean[]{false, true}) { + Map check = new LinkedHashMap<>(requirement); + if (featureSource) check.put("feature", "sourceFeature"); + if (compareTo) { + check.remove("value"); + check.put("compareTo", Map.of("variable", "other")); + } + for (boolean negated : new boolean[]{false, true}) { + List errors = evaluator.evaluateStructured( + List.of(negated ? Map.of("not", List.of(check)) : check)); + assertEquals(valid && expected != negated, errors.isEmpty(), label); + if (!valid) assertTrue(List.of("valid checks", "successful comparison").contains(errors.get(0).expected), label); + } + } + } + } finally { + f.close(); + } + } + } + } + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/cli/VariableChecksTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/cli/VariableChecksTest.java new file mode 100644 index 0000000..97450c9 --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/cli/VariableChecksTest.java @@ -0,0 +1,472 @@ +package com.featurevisor.cli; + +import com.featurevisor.sdk.DatafileContent; +import com.featurevisor.sdk.Evaluation; +import com.featurevisor.sdk.Featurevisor; +import com.featurevisor.sdk.FeaturevisorDiagnostic; +import com.featurevisor.sdk.FeaturevisorLogLevel; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +import java.io.InputStream; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +class VariableChecksTest { + private final ObjectMapper json = new ObjectMapper(); + + @Test + void executesEveryPortableCaseWithAFreshSdk() throws Exception { + InputStream stream = getClass().getClassLoader().getResourceAsStream("conformance/test-checks.json"); + assertNotNull(stream); + Map fixture = json.readValue(stream, new TypeReference>() {}); + assertEquals(3, ((Number) fixture.get("version")).intValue()); + assertEquals(new HashSet<>(Arrays.asList("version", "description", "datafile", "cases")), fixture.keySet()); + @SuppressWarnings("unchecked") List> cases = (List>) fixture.get("cases"); + assertEquals(255, cases.size()); + DatafileContent datafile = json.convertValue(fixture.get("datafile"), DatafileContent.class); + + int executedAnyCases = 0; + for (Map testCase : cases) { + List diagnostics = new ArrayList<>(); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(datafile).logLevel(FeaturevisorLogLevel.FATAL).onDiagnostic(diagnostics::add)); + try { + String owner = testCase.get("owner") instanceof String ? (String) testCase.get("owner") : null; + List failures = new VariableChecks(reference -> { + String feature = reference.get("feature") instanceof String ? (String) reference.get("feature") : null; + String variable = reference.get("variable") instanceof String ? (String) reference.get("variable") : owner; + if (variable == null) return VariableChecks.Resolution.error("variable is required"); + if (!reference.containsKey("variable") && owner != null) reference.put("variable", owner); + diagnostics.clear(); + Evaluation evaluation; + Object value; + if (feature == null) { + if (datafile.getVariables() == null || !datafile.getVariables().containsKey(variable)) { + return VariableChecks.Resolution.error("missing global " + variable); + } + evaluation = f.evaluateVariable(variable); + if ("error".equals(evaluation.getReason()) || evaluation.getError() != null) { + return VariableChecks.Resolution.error("evaluation failed"); + } + value = f.getVariable(variable); + } else { + if (datafile.getFeature(feature) == null || datafile.getFeature(feature).getVariablesSchema() == null + || !datafile.getFeature(feature).getVariablesSchema().containsKey(variable)) { + return VariableChecks.Resolution.error("missing feature variable " + feature + "." + variable); + } + evaluation = f.evaluateVariable(feature, variable); + if ("error".equals(evaluation.getReason()) || evaluation.getError() != null) { + return VariableChecks.Resolution.error("evaluation failed"); + } + value = f.getVariable(feature, variable); + } + for (FeaturevisorDiagnostic diagnostic : diagnostics) { + if (diagnostic.getLevel() == FeaturevisorLogLevel.ERROR) { + return VariableChecks.Resolution.error("getter failed: " + diagnostic.getCode()); + } + } + return VariableChecks.Resolution.value(value); + }, (feature, getter) -> { + if (datafile.getFeature(feature) == null) { + return VariableChecks.Resolution.error("missing feature " + feature); + } + return VariableChecks.Resolution.value( + getter.equals("enabled") ? f.isEnabled(feature) : f.getVariation(feature) + ); + }).evaluate(testCase.get("checks")); + boolean expectedPassed = Boolean.TRUE.equals(testCase.get("passed")); + boolean expectedError = Boolean.TRUE.equals(testCase.get("error")); + assertEquals(expectedPassed, failures.isEmpty(), testCase.get("name") + ": " + failures); + assertEquals(expectedError, failures.stream().anyMatch(value -> value.contains(" error:")), + testCase.get("name") + ": " + failures); + if (((String) testCase.get("name")).toLowerCase(Locale.ROOT).contains("includesany")) executedAnyCases++; + } finally { + f.close(); + } + } + assertEquals(36, executedAnyCases); + } + + @Test + void nativeRunnerDispatchesFiltersAndRunsScenarioChildren() throws Exception { + List calls = new ArrayList<>(); + CLI cli = new CLI((directory, command) -> { + calls.add(command); + if (command.contains(" config ")) return "{\"environments\":false}"; + if (command.contains("--segments") || command.contains("--targets")) return "[]"; + if (command.contains(" build ")) return datafileJson(false); + return executionProtocol("[{\"scenario\":\"ordered values\",\"assertions\":[{" + + "\"description\":\"parent and child\"," + + "\"checks\":[{\"variable\":\"left\",\"operator\":\"greaterThan\",\"compareTo\":{\"variable\":\"right\"}}]," + + "\"children\":[{\"stickyVariables\":{\"left\":4,\"right\":4},\"checks\":[{\"variable\":\"left\",\"operator\":\"equals\",\"compareTo\":{\"variable\":\"right\"}}]}]}]}]"); + }); + int code = new CommandLine(cli).execute("test", "--entityType=scenario", "--set=staging"); + assertEquals(0, code); + assertTrue(calls.stream().anyMatch(command -> command.contains("--entityType=scenario") && command.contains("--set=staging"))); + assertTrue(calls.stream().filter(command -> !command.contains(" config ")).allMatch(command -> command.contains("--set=staging"))); + assertTrue(calls.stream().filter(command -> command.contains(" config ")).noneMatch(command -> command.contains("--set="))); + } + + @Test + void featureChecksUseOnlyRequestedGettersAndReportBothActualValues() throws Exception { + Object checks = json.readValue("[{\"feature\":\"disabledCheckout\",\"variation\":\"control\"}," + + "{\"feature\":\"experiment\",\"enabled\":true,\"variation\":\"control\"}]", Object.class); + List calls = new ArrayList<>(); + List failures = new VariableChecks( + reference -> { throw new AssertionError("variable resolver must not run"); }, + (feature, getter) -> { + calls.add(feature + ":" + getter); + return VariableChecks.Resolution.value( + getter.equals("enabled") + ? false + : feature.equals("experiment") ? "treatment" : "control" + ); + } + ).evaluate(checks); + + assertEquals(Arrays.asList( + "disabledCheckout:variation", "experiment:enabled", "experiment:variation" + ), calls); + assertEquals(1, failures.size()); + assertTrue(failures.get(0).contains("expected enabled true, actual false")); + assertTrue(failures.get(0).contains("expected variation \"control\", actual \"treatment\"")); + } + + @Test + void featureGetterErrorsCannotBeInvertedOrHiddenAndStopBeforeVariation() throws Exception { + Object checks = json.readValue("[{\"not\":[{\"feature\":\"experiment\",\"enabled\":true,\"variation\":\"control\"}]}," + + "{\"or\":[{\"feature\":\"experiment\",\"enabled\":true,\"variation\":\"control\"}," + + "{\"feature\":\"checkout\",\"enabled\":true}]}]", Object.class); + List calls = new ArrayList<>(); + List failures = new VariableChecks( + reference -> { throw new AssertionError("variable resolver must not run"); }, + (feature, getter) -> { + calls.add(feature + ":" + getter); + return VariableChecks.Resolution.error( + getter.equals("enabled") ? "boom" : "evaluation_error" + ); + } + ).evaluate(checks); + + assertEquals(Arrays.asList( + "experiment:enabled", "experiment:enabled" + ), calls); + assertEquals(2, failures.size()); + assertTrue(failures.stream().allMatch(value -> value.contains("enabled getter failed: boom"))); + assertTrue(failures.stream().noneMatch(value -> value.contains("variation getter failed"))); + } + + @Test + void segmentChecksValidateNestEvaluateAndReportEvidence() throws Exception { + Map matches = new HashMap<>(); + matches.put("dutch", true); + matches.put("adult", true); + matches.put("all", true); + VariableChecks checks = new VariableChecks( + reference -> { throw new AssertionError("variable resolver must not run"); }, + (feature, getter) -> { throw new AssertionError("feature resolver must not run"); }, + segment -> matches.containsKey(segment) + ? VariableChecks.Resolution.value(matches.get(segment)) + : VariableChecks.Resolution.error("segment \"" + segment + "\" definition is missing"), + segment -> matches.containsKey(segment) + ? null + : "segment \"" + segment + "\" definition is missing" + ); + Object passing = json.readValue("[{\"segment\":\"all\",\"matches\":true}," + + "{\"segment\":\"dutch\",\"matches\":true}," + + "{\"and\":[{\"segment\":\"adult\",\"matches\":true}," + + "{\"not\":[{\"segment\":\"dutch\",\"matches\":false}]}]}," + + "{\"or\":[{\"segment\":\"dutch\",\"matches\":false}," + + "{\"segment\":\"adult\",\"matches\":true}]}]", Object.class); + assertTrue(checks.evaluateStructured(passing).isEmpty()); + + Object failing = json.readValue("[{\"key\":\"audience\",\"description\":\"Audience is excluded\"," + + "\"segment\":\"dutch\",\"matches\":false}]", Object.class); + List failures = checks.evaluateStructured(failing); + assertEquals(1, failures.size()); + assertTrue(failures.get(0).message.contains("segment \"dutch\"")); + @SuppressWarnings("unchecked") Map evidence = + ((List>) failures.get(0).details.get("evaluated")).get(0); + assertEquals(Collections.singletonMap("segment", "dutch"), evidence.get("reference")); + assertEquals("matches", evidence.get("property")); + assertEquals("equals", evidence.get("operator")); + assertEquals(false, evidence.get("expected")); + assertEquals(true, evidence.get("actual")); + + Object missing = json.readValue("[{\"or\":[{\"segment\":\"dutch\",\"matches\":true}," + + "{\"segment\":\"missing\",\"matches\":false}]}]", Object.class); + List missingFailures = checks.evaluateStructured(missing); + assertEquals(1, missingFailures.size()); + assertTrue(missingFailures.get(0).message.contains("segment \"missing\"")); + + List invalid = Arrays.asList( + "[{\"segment\":\"\",\"matches\":true}]", + "[{\"segment\":\"dutch\"}]", + "[{\"segment\":\"dutch\",\"matches\":\"yes\"}]", + "[{\"matches\":false}]", + "[{\"segment\":\"dutch\",\"matches\":true,\"operator\":\"equals\",\"value\":true}]", + "[{\"segment\":\"dutch\",\"matches\":true,\"feature\":\"checkout\"}]", + "[{\"segment\":\"dutch\",\"matches\":true,\"target\":\"other\"}]" + ); + for (String raw : invalid) { + assertFalse(checks.evaluateStructured(json.readValue(raw, Object.class)).isEmpty(), raw); + } + } + + @Test + void segmentValidationDoesNotResolveSkippedBranchesOrDuplicateReachedLeaves() throws Exception { + Map validations = new HashMap<>(); + Map resolutions = new HashMap<>(); + Set available = new HashSet<>(Arrays.asList("first", "skipped")); + VariableChecks checks = new VariableChecks( + reference -> { throw new AssertionError("variable resolver must not run"); }, + (feature, getter) -> { throw new AssertionError("feature resolver must not run"); }, + segment -> { + resolutions.merge(segment, 1, Integer::sum); + return VariableChecks.Resolution.value(true); + }, + segment -> { + validations.merge(segment, 1, Integer::sum); + return available.contains(segment) + ? null + : "segment \"" + segment + "\" definition is missing"; + } + ); + Object passing = json.readValue("[{\"or\":[{\"segment\":\"first\",\"matches\":true}," + + "{\"segment\":\"skipped\",\"matches\":true}]}]", Object.class); + + assertTrue(checks.evaluateStructured(passing).isEmpty()); + assertEquals(1, validations.get("first")); + assertEquals(1, validations.get("skipped")); + assertEquals(1, resolutions.get("first")); + assertFalse(resolutions.containsKey("skipped")); + } + + @Test + void nativeRunnerUsesAuthoredSegmentsRootAndMergedChildContextOutsideTargetDatafile() throws Exception { + Map definitions = new LinkedHashMap<>(); + definitions.put("all", Collections.singletonMap("conditions", "*")); + definitions.put("dutch", Collections.singletonMap("conditions", Arrays.asList( + linkedMap("attribute", "country", "operator", "equals", "value", "nl") + ))); + definitions.put("dutch-pro", Collections.singletonMap("conditions", Arrays.asList( + linkedMap("attribute", "country", "operator", "equals", "value", "nl"), + linkedMap("attribute", "plan", "operator", "equals", "value", "pro") + ))); + Map root = linkedMap( + "context", Collections.singletonMap("country", "nl"), + "target", "thin", + "at", 0.1, + "checks", Arrays.asList( + linkedMap("segment", "all", "matches", true), + linkedMap("segment", "dutch", "matches", true) + ), + "children", Arrays.asList(linkedMap( + "context", Collections.singletonMap("plan", "pro"), + "stickyVariables", Collections.singletonMap("subject", "ignored"), + "checks", Arrays.asList(linkedMap("segment", "dutch-pro", "matches", true)) + )) + ); + List> tests = Arrays.asList( + linkedMap("scenario", "segment-context", "_segments", definitions, "assertions", Arrays.asList(root)), + linkedMap("variable", "subject", "_segments", definitions, "assertions", Arrays.asList(linkedMap( + "context", Collections.singletonMap("country", "nl"), + "target", "thin", + "expectedValue", "ready", + "checks", Arrays.asList(linkedMap("segment", "dutch", "matches", true)) + ))) + ); + String testsJson = executionProtocol(json.writeValueAsString(tests)); + List calls = new ArrayList<>(); + CLI cli = new CLI((directory, command) -> { + calls.add(command); + if (command.contains(" config ")) return "{\"environments\":false}"; + if (command.contains("--targets")) return "[{\"key\":\"thin\"}]"; + if (command.contains("--segments")) return "[]"; + if (command.contains(" build ")) { + return "{\"schemaVersion\":\"2\",\"revision\":\"target\",\"segments\":{},\"features\":{}," + + "\"variables\":{\"subject\":{\"type\":\"string\",\"defaultValue\":\"ready\"}}}"; + } + return testsJson; + }); + assertEquals(0, new CommandLine(cli).execute("test", "--target=thin", "--onlyFailures")); + assertTrue(calls.stream().anyMatch(command -> command.contains(" build ") && command.contains("--target=thin"))); + } + + @Test + void structuredFailuresPreserveMissingStateAndAuthoredMetadata() throws Exception { + Object checks = json.readValue("[{\"key\":\"missing-value\",\"description\":\"Must be null\"," + + "\"variable\":\"settings\",\"path\":\"absent\",\"operator\":\"isNull\"}]", Object.class); + Map settings = new LinkedHashMap<>(); + settings.put("literal", ""); + List failures = new VariableChecks( + reference -> VariableChecks.Resolution.value(settings) + ).evaluateStructured(checks); + + assertEquals(1, failures.size()); + VariableChecks.CheckFailure failure = failures.get(0); + assertEquals(Arrays.asList("checks", 0), failure.path); + assertEquals("missing-value", failure.details.get("checkKey")); + assertEquals("Must be null", failure.details.get("checkDescription")); + @SuppressWarnings("unchecked") + Map leaf = ((List>) failure.details.get("evaluated")).get(0); + assertEquals(true, leaf.get("actualMissing")); + assertFalse(leaf.containsKey("expectedMissing")); + assertEquals(Collections.singletonMap("$type", "undefined"), leaf.get("actual")); + + Object literalCheck = json.readValue("[{\"variable\":\"settings\",\"path\":\"literal\"," + + "\"operator\":\"equals\",\"value\":\"different\"}]", Object.class); + @SuppressWarnings("unchecked") + Map literalLeaf = ((List>) new VariableChecks( + reference -> VariableChecks.Resolution.value(settings) + ).evaluateStructured(literalCheck).get(0).details.get("evaluated")).get(0); + assertEquals("", literalLeaf.get("actual")); + assertFalse(literalLeaf.containsKey("actualMissing")); + + Object missingExpectedCheck = json.readValue("[{\"variable\":\"settings\",\"path\":\"literal\"," + + "\"operator\":\"equals\",\"compareTo\":{\"variable\":\"settings\",\"path\":\"absent\"}}]", + Object.class); + @SuppressWarnings("unchecked") + Map missingExpectedLeaf = ((List>) new VariableChecks( + reference -> VariableChecks.Resolution.value(settings) + ).evaluateStructured(missingExpectedCheck).get(0).details.get("evaluated")).get(0); + assertEquals(true, missingExpectedLeaf.get("expectedMissing")); + assertFalse(missingExpectedLeaf.containsKey("actualMissing")); + } + + @Test + void emptyChecksValidateAndCompareSupportedContainers() throws Exception { + Map values = new LinkedHashMap<>(); + values.put("emptyString", ""); + values.put("whitespace", " \t"); + values.put("emptyArray", List.of()); + values.put("arrayWithNull", Arrays.asList((Object) null)); + values.put("emptyObject", Map.of()); + values.put("objectWithNull", Collections.singletonMap("value", null)); + values.put("null", null); + values.put("number", 0); + values.put("boolean", false); + values.put("nested", Map.of("items", List.of())); + VariableChecks checks = new VariableChecks(reference -> VariableChecks.Resolution.value(values)); + + Object passing = json.readValue("[" + + "{\"variable\":\"subject\",\"path\":\"emptyString\",\"operator\":\"empty\"}," + + "{\"variable\":\"subject\",\"path\":\"whitespace\",\"operator\":\"notEmpty\"}," + + "{\"variable\":\"subject\",\"path\":\"emptyArray\",\"operator\":\"empty\"}," + + "{\"variable\":\"subject\",\"path\":\"arrayWithNull\",\"operator\":\"notEmpty\"}," + + "{\"variable\":\"subject\",\"path\":\"emptyObject\",\"operator\":\"empty\"}," + + "{\"variable\":\"subject\",\"path\":\"objectWithNull\",\"operator\":\"notEmpty\"}," + + "{\"variable\":\"subject\",\"path\":\"nested.items\",\"operator\":\"empty\"}]", Object.class); + assertTrue(checks.evaluateStructured(passing).isEmpty()); + + Object nullAndMissing = json.readValue("[" + + "{\"variable\":\"subject\",\"path\":\"null\",\"operator\":\"empty\"}," + + "{\"variable\":\"subject\",\"path\":\"null\",\"operator\":\"notEmpty\"}," + + "{\"variable\":\"subject\",\"path\":\"missing\",\"operator\":\"empty\"}," + + "{\"variable\":\"subject\",\"path\":\"missing\",\"operator\":\"notEmpty\"}]", Object.class); + List falseResults = checks.evaluateStructured(nullAndMissing); + assertEquals(4, falseResults.size()); + assertTrue(falseResults.stream().noneMatch(failure -> failure.message.contains(" error:"))); + + for (String path : List.of("number", "boolean")) { + for (String operator : List.of("empty", "notEmpty")) { + Object incompatible = json.readValue("[{\"variable\":\"subject\",\"path\":\"" + path + + "\",\"operator\":\"" + operator + "\"}]", Object.class); + List failures = checks.evaluateStructured(incompatible); + assertEquals(1, failures.size()); + assertTrue(failures.get(0).message.contains(" error:"), failures.get(0).message); + } + } + } + + @Test + void emptyCheckErrorsCannotBeSkippedOrInverted() throws Exception { + VariableChecks values = new VariableChecks(reference -> VariableChecks.Resolution.value(Map.of())); + for (String invalid : List.of( + "[{\"or\":[{\"variable\":\"subject\",\"operator\":\"empty\"},{\"variable\":\"subject\",\"operator\":\"empty\",\"value\":1}]}]", + "[{\"variable\":\"subject\",\"operator\":\"empty\",\"compareTo\":{\"variable\":\"subject\"}}]", + "[{\"variable\":\"subject\",\"operator\":\"notEmpty\",\"regexFlags\":\"i\"}]")) { + List failures = values.evaluateStructured(json.readValue(invalid, Object.class)); + assertEquals(1, failures.size()); + assertTrue(failures.get(0).message.contains(" error:"), failures.get(0).message); + } + + VariableChecks scalar = new VariableChecks(reference -> VariableChecks.Resolution.value(0)); + Object scalarOr = json.readValue("[{\"or\":[{\"variable\":\"subject\",\"operator\":\"empty\"}," + + "{\"variable\":\"subject\",\"operator\":\"equals\",\"value\":0}]}]", Object.class); + assertTrue(scalar.evaluateStructured(scalarOr).get(0).message.contains(" error:")); + Object scalarNot = json.readValue("[{\"not\":[{\"variable\":\"subject\",\"operator\":\"notEmpty\"}]}]", Object.class); + assertTrue(scalar.evaluateStructured(scalarNot).get(0).message.contains(" error:")); + + VariableChecks getterError = new VariableChecks(reference -> VariableChecks.Resolution.error("getter failed")); + List getterFailures = getterError.evaluateStructured(scalarNot); + assertEquals(1, getterFailures.size()); + assertTrue(getterFailures.get(0).message.contains("getter failed")); + assertTrue(getterFailures.get(0).message.contains(" error:")); + } + + @Test + void checkKeysAreUniqueOnlyAmongSiblingsAndDoNotChangeMatching() throws Exception { + Object mixed = json.readValue("[{\"key\":\"outer\",\"and\":[" + + "{\"key\":\"nested\",\"variable\":\"value\",\"operator\":\"equals\",\"value\":3}," + + "{\"variable\":\"value\",\"operator\":\"equals\",\"value\":3}]}," + + "{\"key\":\"nested\",\"variable\":\"value\",\"operator\":\"equals\",\"value\":3}]", + Object.class); + assertTrue(new VariableChecks(reference -> VariableChecks.Resolution.value(3)) + .evaluateStructured(mixed).isEmpty()); + + Object duplicate = json.readValue("[{\"key\":\"same\",\"variable\":\"value\"," + + "\"operator\":\"equals\",\"value\":3},{\"key\":\"same\",\"variable\":\"value\"," + + "\"operator\":\"equals\",\"value\":3}]", Object.class); + List failures = new VariableChecks( + reference -> VariableChecks.Resolution.value(3) + ).evaluateStructured(duplicate); + assertEquals(1, failures.size()); + assertTrue(failures.get(0).message.contains("duplicate sibling key")); + } + + @Test + void nativeRunnerReturnsFailureForPredicateMalformedSpecAndGetterDiagnostic() { + assertEquals(1, runnerCode("[{\"scenario\":\"failure\",\"assertions\":[{\"checks\":[{\"variable\":\"left\",\"operator\":\"equals\",\"value\":99}]}]}]", false)); + assertEquals(1, runnerCode("[{\"scenario\":\"malformed\",\"assertions\":[]}]", false)); + assertEquals(1, runnerCode("[{\"scenario\":\"hybrid\",\"variable\":\"left\",\"assertions\":[{\"checks\":[{\"variable\":\"left\",\"operator\":\"equals\",\"value\":3}]}]}]", false)); + assertEquals(1, runnerCode("[{\"scenario\":\"bad json\",\"assertions\":[{\"checks\":[{\"variable\":\"broken\",\"operator\":\"isNull\"}]}]}]", true)); + assertEquals(1, runnerCode("[{\"scenario\":\"feature diagnostic\",\"assertions\":[{\"checks\":[{\"feature\":\"diagnostic\",\"enabled\":true,\"variation\":\"control\"}]}]}]", true)); + } + + private int runnerCode(String tests, boolean malformedJson) { + CLI cli = new CLI((directory, command) -> { + if (command.contains(" config ")) return "{\"environments\":false}"; + if (command.contains("--segments") || command.contains("--targets")) return "[]"; + if (command.contains(" build ")) return datafileJson(malformedJson); + return executionProtocol(tests); + }); + return new CommandLine(cli).execute("test", "--onlyFailures"); + } + + private String datafileJson(boolean malformedJson) { + return "{\"schemaVersion\":\"2\",\"revision\":\"checks\",\"segments\":{},\"features\":" + + (malformedJson ? "{\"diagnostic\":{\"bucketBy\":[\"userId\"],\"conditions\":[{\"variable\":\"broken\",\"operator\":\"isNull\"}],\"variations\":[{\"value\":\"control\"}],\"traffic\":[{\"key\":\"all\",\"segments\":\"*\",\"percentage\":100000,\"allocation\":[{\"variation\":\"control\",\"range\":[0,100000]}]}]}}" : "{}") + + ",\"variables\":{" + + "\"left\":{\"type\":\"integer\",\"defaultValue\":3}," + + "\"right\":{\"type\":\"integer\",\"defaultValue\":2}" + + (malformedJson ? ",\"broken\":{\"type\":\"json\",\"defaultValue\":\"{\"}" : "") + + "}}"; + } + + private String executionProtocol(String tests) { + return "{\"kind\":\"featurevisor.test-execution\",\"schemaVersion\":1,\"tests\":" + + tests + "}"; + } + + @SuppressWarnings("unchecked") + private static > T linkedMap(Object... values) { + Map result = new LinkedHashMap<>(); + for (int i = 0; i < values.length; i += 2) result.put((String) values[i], values[i + 1]); + return (T) result; + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/ConditionsTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/ConditionsTest.java index 42c1070..92d1486 100644 --- a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/ConditionsTest.java +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/ConditionsTest.java @@ -85,6 +85,40 @@ public void testIncludesAllPrimitivesAndRegexSearchSemantics() { assertFalse(evaluationData.allConditionsAreMatched(condition("text", Operator.NOT_INCLUDES, "value"), context)); } + @Test + public void testEmptyAndNotEmptyUseShallowContainerContents() throws Exception { + Map context = new HashMap<>(); + context.put("emptyString", ""); + context.put("whitespace", " \t"); + context.put("emptyArray", List.of()); + context.put("arrayWithNull", Arrays.asList((Object) null)); + context.put("emptyObject", Map.of()); + context.put("objectWithNull", java.util.Collections.singletonMap("value", null)); + context.put("null", null); + context.put("number", 0); + context.put("boolean", false); + context.put("nested", Map.of("items", List.of())); + + for (String attribute : List.of("emptyString", "emptyArray", "emptyObject", "nested.items")) { + assertTrue(evaluationData.allConditionsAreMatched(condition(attribute, Operator.EMPTY, null), context), attribute); + assertFalse(evaluationData.allConditionsAreMatched(condition(attribute, Operator.NOT_EMPTY, null), context), attribute); + } + for (String attribute : List.of("whitespace", "arrayWithNull", "objectWithNull")) { + assertFalse(evaluationData.allConditionsAreMatched(condition(attribute, Operator.EMPTY, null), context), attribute); + assertTrue(evaluationData.allConditionsAreMatched(condition(attribute, Operator.NOT_EMPTY, null), context), attribute); + } + for (String attribute : List.of("null", "number", "boolean", "missing")) { + assertFalse(evaluationData.allConditionsAreMatched(condition(attribute, Operator.EMPTY, null), context), attribute); + assertFalse(evaluationData.allConditionsAreMatched(condition(attribute, Operator.NOT_EMPTY, null), context), attribute); + } + + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + assertEquals(Operator.EMPTY, mapper.readValue( + "{\"attribute\":\"value\",\"operator\":\"empty\"}", Condition.class).getOperator()); + assertEquals(Operator.NOT_EMPTY, mapper.readValue( + "{\"attribute\":\"value\",\"operator\":\"notEmpty\"}", Condition.class).getOperator()); + } + private Condition condition(String attribute, Operator operator, Object value) { Condition condition = new Condition(); condition.setAttribute(attribute); @@ -656,6 +690,148 @@ public void testOperatorBefore() { assertFalse(evaluationData.allConditionsAreMatched(conditions, Map.of("date", "2023-05-12T00:00:00"))); } + @Test + public void testHostDateMillisecondPrecisionAndUtcRange() { + Conditions.GetRegex unused = (pattern, flags) -> java.util.regex.Pattern.compile(pattern); + java.time.Instant preEpoch = java.time.Instant.parse("1969-12-31T23:59:59.9995Z"); + assertFalse(Conditions.valueIsMatched(preEpoch, true, Operator.BEFORE, + "1969-12-31T23:59:59.999Z", "", unused)); + assertTrue(Conditions.valueIsMatched(preEpoch, true, Operator.BEFORE, + "1970-01-01T00:00:00Z", "", unused)); + assertFalse(Conditions.valueIsMatched(java.time.Instant.parse("+10000-01-01T00:00:00Z"), true, + Operator.BEFORE, "9999-12-31T23:59:59Z", "", unused)); + } + + private boolean dateMatches(Object actual, Operator operator, Object expected) { + Condition condition = new Condition(); + condition.setAttribute("date"); + condition.setOperator(operator); + condition.setValue(expected); + return evaluationData.allConditionsAreMatched(condition, Map.of("date", actual)); + } + + private List nativeDates(java.time.Instant instant) { + List values = new ArrayList<>(List.of(instant, java.util.Date.from(instant), + java.sql.Timestamp.from(instant), instant.atOffset(java.time.ZoneOffset.ofHours(9)), + instant.atOffset(java.time.ZoneOffset.ofHours(-8)))); + for (String zone : List.of("UTC", "America/Los_Angeles", "Asia/Tokyo")) { + values.add(instant.atZone(java.time.ZoneId.of(zone))); + } + return values; + } + + @Test + public void testNativeDateTypesRepresentInstantsOnBothSides() { + for (Object value : nativeDates(java.time.Instant.parse("2026-01-01T00:00:00Z"))) { + assertTrue(dateMatches(value, Operator.BEFORE, "2026-01-01T00:00:00.001Z"), value.toString()); + assertTrue(dateMatches(value, Operator.AFTER, "2025-12-31T23:59:59.999Z"), value.toString()); + assertTrue(dateMatches("2026-01-01T00:00:00.001Z", Operator.AFTER, value)); + assertTrue(dateMatches("2025-12-31T23:59:59.999Z", Operator.BEFORE, value)); + assertFalse(dateMatches(value, Operator.BEFORE, "2026-01-01T00:00:00Z")); + assertFalse(dateMatches(value, Operator.AFTER, "2026-01-01T00:00:00Z")); + } + } + + @Test + public void testNativeDateOffsetBoundsPreserveSeconds() { + java.time.Instant instant = java.time.Instant.parse("2026-01-01T00:00:00Z"); + for (int seconds : List.of(-50400, 50400, -50399, 50399, -1, 1)) { + java.time.OffsetDateTime date = instant.atOffset(java.time.ZoneOffset.ofTotalSeconds(seconds)); + for (Object value : List.of(date, date.toZonedDateTime())) { + assertTrue(dateMatches(value, Operator.BEFORE, instant.plusMillis(1).toString())); + assertTrue(dateMatches(value, Operator.AFTER, instant.minusMillis(1).toString())); + assertTrue(dateMatches(instant.minusMillis(1).toString(), Operator.BEFORE, value)); + assertTrue(dateMatches(instant.plusMillis(1).toString(), Operator.AFTER, value)); + for (Operator operator : List.of(Operator.BEFORE, Operator.AFTER)) { + assertFalse(dateMatches(value, operator, instant.toString())); + assertFalse(dateMatches(instant.toString(), operator, value)); + } + } + } + for (int seconds : List.of(-54000, 54000, -50401, 50401)) { + java.time.OffsetDateTime date = instant.atOffset(java.time.ZoneOffset.ofTotalSeconds(seconds)); + for (Object value : List.of(date, date.toZonedDateTime())) { + for (Operator operator : List.of(Operator.BEFORE, Operator.AFTER)) { + for (String boundary : List.of(instant.minusMillis(1).toString(), instant.plusMillis(1).toString())) { + assertFalse(assertDoesNotThrow(() -> dateMatches(value, operator, boundary)), value.toString()); + assertFalse(assertDoesNotThrow(() -> dateMatches(boundary, operator, value)), value.toString()); + } + } + } + assertTrue(dateMatches(date.toInstant(), Operator.BEFORE, instant.plusMillis(1).toString())); + assertTrue(dateMatches(java.util.Date.from(date.toInstant()), Operator.AFTER, instant.minusMillis(1).toString())); + } + } + + @Test + public void testAllNativeDateTypesTruncateFractionsIncludingPreEpoch() { + for (String boundary : List.of("2026-01-01T00:00:00Z", "1969-12-31T23:59:59.999Z")) { + java.time.Instant instant = java.time.Instant.parse(boundary); + for (Object value : nativeDates(instant.plusNanos(999999))) { + for (Operator operator : List.of(Operator.BEFORE, Operator.AFTER)) { + assertFalse(dateMatches(value, operator, boundary), value.toString()); + assertFalse(dateMatches(boundary, operator, value), value.toString()); + } + assertTrue(dateMatches(value, Operator.BEFORE, instant.plusMillis(1).toString())); + assertTrue(dateMatches(value, Operator.AFTER, instant.minusMillis(1).toString())); + } + } + } + + @Test + public void testInvalidNativeDatesNeverMatchOrThrow() { + List invalid = new ArrayList<>(List.of( + java.time.LocalDate.of(2026, 1, 1), java.time.LocalDateTime.of(2026, 1, 1, 0, 0), + java.sql.Date.valueOf("2026-01-01"), java.sql.Time.valueOf("00:00:00"), + java.time.Instant.MIN, java.time.Instant.MAX, + java.time.Instant.parse("0000-12-31T23:59:59.999999999Z"), + java.time.Instant.parse("+10000-01-01T00:00:00Z"), + new java.util.Date(Long.MIN_VALUE), new java.util.Date(Long.MAX_VALUE))); + for (String text : List.of("0000-12-31T23:00:00-02:00", "+10000-01-01T01:00:00+02:00", + "0001-01-01T00:00:00+01:00", "9999-12-31T23:59:59-01:00")) { + java.time.OffsetDateTime date = java.time.OffsetDateTime.parse(text); + invalid.add(date); + invalid.add(date.toZonedDateTime()); + } + for (Object value : invalid) { + for (Operator operator : List.of(Operator.BEFORE, Operator.AFTER)) { + assertFalse(assertDoesNotThrow(() -> dateMatches(value, operator, "2026-01-01T00:00:00Z")), value.toString()); + assertFalse(assertDoesNotThrow(() -> dateMatches("2026-01-01T00:00:00Z", operator, value)), value.toString()); + } + } + } + + @Test + public void testNativeDateRangeEndpointsAreValid() { + for (String text : List.of("0001-01-01T00:00:00Z", "9999-12-31T23:59:59.999Z")) { + java.time.Instant instant = java.time.Instant.parse(text); + List values = List.of(instant, java.util.Date.from(instant), java.sql.Timestamp.from(instant), + instant.atOffset(java.time.ZoneOffset.UTC), instant.atZone(java.time.ZoneOffset.UTC)); + for (Object value : values) { + assertTrue(dateMatches(value, text.startsWith("0001") ? Operator.BEFORE : Operator.AFTER, + "2026-01-01T00:00:00Z"), value.toString()); + } + } + } + + @Test + public void testPortableStringDateFractionalPrecision() { + Conditions.GetRegex unused = (pattern, flags) -> java.util.regex.Pattern.compile(pattern); + for (String digits : List.of("1239", "123999", "1239999", "123999999")) { + String value = "2024-01-01T00:00:00." + digits + "Z"; + assertFalse(Conditions.valueIsMatched(value, true, Operator.BEFORE, + "2024-01-01T00:00:00.123Z", "", unused), digits); + assertTrue(Conditions.valueIsMatched(value, true, Operator.BEFORE, + "2024-01-01T00:00:00.124Z", "", unused), digits); + } + assertFalse(Conditions.valueIsMatched("2024-01-01T00:00:00.1234567890Z", true, + Operator.BEFORE, "2024-01-01T00:00:01Z", "", unused)); + assertFalse(Conditions.valueIsMatched("1969-12-31T23:59:59.999999999Z", true, + Operator.BEFORE, "1969-12-31T23:59:59.999Z", "", unused)); + assertTrue(Conditions.valueIsMatched("1969-12-31T23:59:59.999999999Z", true, + Operator.BEFORE, "1970-01-01T00:00:00Z", "", unused)); + } + @Test public void testOperatorAfter() { List conditions = new ArrayList<>(); diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/DatafileContentTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/DatafileContentTest.java index 4c32915..d43ce9a 100644 --- a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/DatafileContentTest.java +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/DatafileContentTest.java @@ -413,11 +413,8 @@ public void testStagingDatafile() throws Exception { assertNotNull(barVariation.getVariableOverrides()); assertTrue(barVariation.getVariableOverrides().containsKey("hero")); - // Test ranges - assertNotNull(bar.getRanges()); - assertEquals(1, bar.getRanges().size()); - assertEquals(0, bar.getRanges().get(0).getStart()); - assertEquals(50000, bar.getRanges().get(0).getEnd()); + // Unknown feature fields are not part of the decoded model. + assertFalse(new com.fasterxml.jackson.databind.ObjectMapper().valueToTree(bar).has("ranges")); } @Test @@ -871,6 +868,5 @@ public void testEdgeCases() throws Exception { assertNull(emptyFeature.getVariations()); assertNull(emptyFeature.getVariablesSchema()); assertNull(emptyFeature.getForce()); - assertNull(emptyFeature.getRanges()); } } diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/DependencyEdgeReuseTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/DependencyEdgeReuseTest.java new file mode 100644 index 0000000..9bae669 --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/DependencyEdgeReuseTest.java @@ -0,0 +1,97 @@ +package com.featurevisor.sdk; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +final class DependencyEdgeReuseTest { + private static final ObjectMapper JSON = new ObjectMapper(); + + private DatafileContent definitions(String schema) throws Exception { + return DatafileContent.fromJson("{\"schemaVersion\":\"" + schema + "\",\"revision\":\"one\"," + + "\"segments\":{\"s\":{\"conditions\":\"*\"}}," + + "\"features\":{\"flag\":{\"hash\":\"flag\",\"conditions\":\"s\",\"bucketBy\":\"userId\",\"traffic\":[]}}," + + "\"variables\":{\"source\":{\"hash\":\"source\",\"type\":\"string\",\"defaultValue\":\"yes\"}," + + "\"alias\":{\"hash\":\"alias\",\"type\":\"string\",\"compose\":\"source\"}," + + "\"broken\":{\"hash\":\"broken\",\"type\":\"string\",\"conditions\":\"[broken\",\"defaultValue\":\"no\"}}}"); + } + + private DatafileContent normalized(String schema) throws Exception { + return Normalize.incoming(definitions(schema), new ArrayList<>()); + } + + @Test void fullRefreshReconstructsEquivalentEdgesInBothSchemas() throws Exception { + for (String schema : List.of("2", "4")) for (boolean replace : new boolean[]{false, true}) { + DatafileContent before = normalized(schema); + Dependencies.Index old = Dependencies.create(before); + Dependencies.Index next = Dependencies.update(old, before, normalized(schema)); + assertEquals(old.owners, next.owners); + DatafileContent changed = normalized(schema); + changed.getVariables().get("alias").setCompose("other"); + Dependencies.Index refreshed = Dependencies.update(next, before, changed); + assertEquals(Set.of("1:other"), refreshed.owners.get("1:alias")); + assertEquals(Set.of("1:source"), old.owners.get("1:alias")); + Dependencies.Index empty = Dependencies.update(refreshed, changed, + DatafileContent.fromJson("{\"schemaVersion\":\"" + schema + "\",\"revision\":\"empty\",\"segments\":{},\"features\":{},\"variables\":{}}")); + assertTrue(empty.owners.isEmpty()); + for (Map> reverse : empty.dependents) assertTrue(reverse.isEmpty()); + } + } + + @Test void fullRefreshDoesNotReuseIncomingDiagnosticSlots() throws Exception { + for (String schema : List.of("2", "4")) for (boolean replace : new boolean[]{false, true}) { + List diagnostics = new ArrayList<>(); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(definitions(schema)).logLevel(FeaturevisorLogLevel.ERROR).onDiagnostic(diagnostics::add)); + try { + assertNull(sdk.getVariable("broken")); + for (int load = 0; load < 2; load++) { + assertTrue(sdk.setDatafile(definitions(schema), replace)); + assertEquals(load + (schema.equals("2") ? 2 : 1), + diagnostics.stream().filter(d -> "conditions_parse_error".equals(d.getCode())).count()); + assertNull(sdk.getVariable("broken")); + assertNull(sdk.getVariable("broken")); + assertEquals(load + 2, diagnostics.stream().filter(d -> "conditions_parse_error".equals(d.getCode())).count()); + } + assertTrue(sdk.setDatafile(DatafileContent.fromJson("{\"schemaVersion\":\"" + schema + + "\",\"revision\":\"empty\",\"segments\":{},\"features\":{},\"variables\":{}}"), true)); + assertNull(sdk.getVariable("alias")); + assertTrue(sdk.getFeatureKeys().isEmpty()); + } finally { sdk.close(); } + } + } + + @Test void cyclicHostPayloadsFallBackWithoutAddingSelectorEdges() throws Exception { + DatafileContent before = definitions("4"), after = definitions("4"); + for (DatafileContent datafile : List.of(before, after)) { + Map payload = new java.util.HashMap<>(); + payload.put("conditions", payload); + datafile.getVariables().get("source").setDefaultValue(payload); + } + Dependencies.Index next = Dependencies.update(Dependencies.create(before), before, after); + assertEquals(Set.of("1:alias"), next.dependents[1].get("source")); + assertFalse(next.dependents[2].containsKey("conditions")); + } + + @Test void payloadSelectorNamesNeverBecomeEdgesAndHashlessEventsRemainConservative() throws Exception { + Map input = Map.of("schemaVersion", "4", "revision", "one", "segments", Map.of(), + "features", Map.of(), "variables", Map.of("value", Map.of("type", "object", "defaultValue", + Map.of("conditions", "phantom", "compose", "source", "feature", "flag", "variable", "hidden")))); + DatafileContent before = JSON.convertValue(input, DatafileContent.class); + Dependencies.Index old = Dependencies.create(before); + Dependencies.Index next = Dependencies.update(old, before, JSON.convertValue(input, DatafileContent.class)); + for (Map> reverse : next.dependents) assertTrue(reverse.isEmpty()); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().datafile(before).onDiagnostic(d -> {})); + List events = new ArrayList<>(); + try { + sdk.on(FeaturevisorEventName.DATAFILE_SET, events::add); + assertTrue(sdk.setDatafile(JSON.convertValue(input, DatafileContent.class))); + assertEquals(Set.of("value"), new HashSet<>((List) events.get(0).get("variables"))); + } finally { sdk.close(); } + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/DeprecationDiagnosticsTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/DeprecationDiagnosticsTest.java new file mode 100644 index 0000000..2e95913 --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/DeprecationDiagnosticsTest.java @@ -0,0 +1,39 @@ +package com.featurevisor.sdk; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.TreeSet; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +final class DeprecationDiagnosticsTest { + @Test + void reportsDeprecatedFeaturesAndVariablesWithOneCodePerKind() throws Exception { + List captured = new ArrayList<>(); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(DatafileContent.fromJson("{\"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}}}")) + .logLevel(FeaturevisorLogLevel.WARN) + .onDiagnostic(diagnostic -> { + if (diagnostic.getCode().contains("deprecated")) captured.add(diagnostic.getCode()); + })); + try { + sdk.isEnabled("legacy"); + assertEquals(List.of("deprecated_feature"), new ArrayList<>(new TreeSet<>(captured))); + + captured.clear(); + sdk.getVariable("legacy", "old", new HashMap<>()); + assertEquals(List.of("deprecated_feature", "deprecated_variable"), new ArrayList<>(new TreeSet<>(captured))); + + captured.clear(); + sdk.getVariable("oldGlobal"); + assertEquals(List.of("deprecated_variable"), captured); + } finally { + sdk.close(); + } + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EvaluateStickyTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EvaluateStickyTest.java index f68363f..ba0837e 100644 --- a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EvaluateStickyTest.java +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EvaluateStickyTest.java @@ -68,7 +68,7 @@ public void testEvaluateStickyWithFlagType() { assertEquals(Evaluation.TYPE_FLAG, result.getType()); assertEquals("test-feature", result.getFeatureKey()); assertEquals(Evaluation.REASON_STICKY, result.getReason()); - assertNotNull(result.getSticky()); + assertNotNull(result.getStickyFeature()); assertTrue(result.getEnabled()); } @@ -91,7 +91,7 @@ public void testEvaluateStickyWithFlagTypeDisabled() { assertEquals(Evaluation.TYPE_FLAG, result.getType()); assertEquals("test-feature", result.getFeatureKey()); assertEquals(Evaluation.REASON_STICKY, result.getReason()); - assertNotNull(result.getSticky()); + assertNotNull(result.getStickyFeature()); assertFalse(result.getEnabled()); } @@ -200,7 +200,7 @@ public void testEvaluateStickyWithVariableType() { @Test public void testEvaluateStickyWithVariableTypeNullValue() { - // Should return null when variable value is null + // Explicit null is a sticky value, not a missing entry. Map variables = new HashMap<>(); variables.put("test-variable", null); @@ -217,7 +217,10 @@ public void testEvaluateStickyWithVariableTypeNullValue() { Evaluation result = EvaluateSticky.evaluateSticky(options); - assertNull(result); + assertNotNull(result); + assertEquals(Evaluation.REASON_STICKY, result.getReason()); + assertTrue(result.hasVariableValue()); + assertNull(result.getVariableValue()); } @Test diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EvaluationBoundaryTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EvaluationBoundaryTest.java new file mode 100644 index 0000000..a90fa48 --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EvaluationBoundaryTest.java @@ -0,0 +1,411 @@ +package com.featurevisor.sdk; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +final class EvaluationBoundaryTest { + private static final ObjectMapper JSON = new ObjectMapper(); + private static final String ROOT_DATA = """ + {"schemaVersion":"4","revision":"candidate","segments":{},"features":{}, + "variables":{"value":{"type":"string","defaultValue":"candidate"}}} + """; + + @Test void rootParserRejectsTrailingTokensButAcceptsTrailingWhitespace() throws Exception { + assertEquals("candidate", DatafileContent.fromJson(ROOT_DATA + " \n\t ").getRevision()); + for (String tail : List.of(" {}", " true", " null", " 1", " garbage")) + assertThrows(com.fasterxml.jackson.core.JsonProcessingException.class, + () -> DatafileContent.fromJson(ROOT_DATA + tail), tail); + } + + @Test void constructorRejectsTrailingRootTokensWithoutThrowingOrAcceptingThePrefix() { + for (String tail : List.of(" {}", " true", " garbage")) { + List diagnostics = new ArrayList<>(); + Featurevisor f = assertDoesNotThrow(() -> Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafileString(ROOT_DATA + tail).logLevel(FeaturevisorLogLevel.ERROR).onDiagnostic(diagnostics::add))); + try { + assertEquals("unknown", f.getRevision()); + assertTrue(f.getVariableKeys().isEmpty()); + assertEquals(1, diagnostics.size()); + FeaturevisorDiagnostic diagnostic = diagnostics.get(0); + assertEquals("invalid_datafile", diagnostic.getCode()); + assertEquals("Could not parse datafile", diagnostic.getMessage()); + assertEquals(FeaturevisorLogLevel.ERROR, diagnostic.getLevel()); + assertInstanceOf(com.fasterxml.jackson.core.JsonProcessingException.class, diagnostic.getOriginalError()); + assertTrue(f.setDatafile(ROOT_DATA)); + assertEquals("candidate", f.getRevision()); + assertEquals("candidate", f.getVariable("value")); + } finally { f.close(); } + } + } + + @Test void stringSetterRejectsTrailingRootTokensWithoutChangingTheAcceptedSnapshot() { + for (boolean replace : List.of(false, true)) { + Featurevisor[] holder = {null}; + List diagnostics = new ArrayList<>(); + holder[0] = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafileString(ROOT_DATA.replace("candidate", "accepted")) + .logLevel(FeaturevisorLogLevel.ERROR).onDiagnostic(diagnostic -> { + diagnostics.add(diagnostic); + assertEquals("accepted", holder[0].getRevision()); + assertEquals("accepted", holder[0].getVariable("value")); + })); + AtomicInteger events = new AtomicInteger(); + holder[0].on(FeaturevisorEventName.DATAFILE_SET, details -> events.incrementAndGet()); + try { + int rejected = 0; + for (String tail : List.of(" {}", " true", " null", " 1", " garbage")) { + assertFalse(holder[0].setDatafile(ROOT_DATA + tail, replace), tail); + assertEquals("accepted", holder[0].getRevision()); + assertEquals("accepted", holder[0].getVariable("value")); + assertEquals(0, events.get()); + assertEquals(++rejected, diagnostics.size()); + FeaturevisorDiagnostic diagnostic = diagnostics.get(rejected - 1); + assertEquals("invalid_datafile", diagnostic.getCode()); + assertEquals("Could not parse datafile", diagnostic.getMessage()); + assertEquals(FeaturevisorLogLevel.ERROR, diagnostic.getLevel()); + assertInstanceOf(com.fasterxml.jackson.core.JsonProcessingException.class, diagnostic.getOriginalError()); + } + assertTrue(holder[0].setDatafile(ROOT_DATA + " \n\t ", replace)); + assertEquals("candidate", holder[0].getRevision()); + assertEquals("candidate", holder[0].getVariable("value")); + assertEquals(1, events.get()); + assertEquals(rejected, diagnostics.size()); + } finally { holder[0].close(); } + } + } + + private static DatafileContent data(String version, Map segments, + Map features, Map variables) throws Exception { + return DatafileContent.fromJson(JSON.writeValueAsString(Map.of( + "schemaVersion", version, "revision", "evaluation-boundaries", "segments", segments, + "features", features, "variables", variables))); + } + + @Test void trailingSelectorContentFailsThroughNotAndReportsOncePerEntityLoad() throws Exception { + for (String version : List.of("2", "4")) { + for (String raw : List.of("{\"not\":[]} trailing", "{\"not\":[]} {}", "\"*\" false")) { + List diagnostics = new ArrayList<>(); + DatafileContent incoming = data(version, Map.of("bad", Map.of("conditions", raw)), + Map.of(), Map.of("subject", Map.of("type", "string", "defaultValue", "wrong", + "disabledValue", "blocked", "conditions", Map.of("not", List.of("bad"))))); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(incoming).logLevel(FeaturevisorLogLevel.ERROR).onDiagnostic(diagnostics::add)); + try { + assertEquals(version.equals("2") ? 1 : 0, diagnostics.size()); + for (int run = 0; run < 2; run++) { + assertEquals("conditions_unmet", f.evaluateVariable("subject").getReason(), raw); + assertEquals("blocked", f.getVariable("subject"), raw); + } + assertEquals(1, diagnostics.size()); + assertEquals("conditions_parse_error", diagnostics.get(0).getCode()); + assertEquals(Map.of("conditions", raw, "entityType", "segment", "entityKey", "bad"), + diagnostics.get(0).getDetails()); + assertEquals(raw, incoming.getSegments().get("bad").getConditions()); + } finally { f.close(); } + } + } + } + + @Test void laterSourcesObserveCallbackDatafileChangesInParentAndChildCalls() throws Exception { + for (boolean condition : List.of(false, true)) { + for (boolean childMode : List.of(false, true)) { + Featurevisor[] holder = {null}; + AtomicInteger reads = new AtomicInteger(); + DatafileContent update = data("4", Map.of(), Map.of(), + Map.of("source", Map.of("type", "integer", "defaultValue", 2))); + FeaturevisorModule module = new FeaturevisorModule("update").afterEvaluation((result, options) -> { + if ("source".equals(options.getVariableKey())) reads.incrementAndGet(); + if ("mutator".equals(options.getVariableKey())) assertTrue(holder[0].setDatafile(update)); + return result; + }); + holder[0] = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(data("4", Map.of(), Map.of(), Map.of( + "source", Map.of("type", "integer", "defaultValue", 1), + "mutator", Map.of("type", "boolean", "defaultValue", true), + "subject", condition ? Map.of("compose", "source", "conditions", List.of( + Map.of("variable", "source", "operator", "equals", "value", 1), + Map.of("variable", "mutator", "operator", "equals", "value", true), + Map.of("variable", "source", "operator", "equals", "value", 2))) : Map.of("compose", List.of( + Map.of("key", "before", "variable", "source"), + Map.of("key", "mutation", "variable", "mutator"), + Map.of("key", "after", "variable", "source")))))) + .modules(List.of(module)).logLevel(FeaturevisorLogLevel.FATAL)); + ChildInstance child = holder[0].spawn(); + try { + assertEquals(condition ? 2 : Map.of("before", 1, "mutation", true, "after", 2), + childMode ? child.getVariable("subject") : holder[0].getVariable("subject")); + assertEquals(condition ? 3 : 2, reads.get()); + assertEquals(2, holder[0].getVariable("source")); + } finally { child.close(); holder[0].close(); } + } + } + } + + @Test void laterSourcesObserveEffectiveContextMutationsWithoutRepeatingMemoizedReads() throws Exception { + AtomicInteger reads = new AtomicInteger(); + FeaturevisorModule module = new FeaturevisorModule("context").afterEvaluation((result, options) -> { + if ("source".equals(options.getVariableKey())) { + reads.incrementAndGet(); result.variableValue(options.getContext().get("value")); + } + if ("mutator".equals(options.getVariableKey())) options.getContext().put("value", 2); + return result; + }); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(data("4", Map.of(), Map.of(), Map.of( + "source", Map.of("type", "integer", "defaultValue", 0), + "mutator", Map.of("type", "boolean", "defaultValue", true), + "subject", Map.of("compose", List.of( + Map.of("key", "before", "variable", "source"), + Map.of("key", "duplicate", "variable", "source"), + Map.of("key", "mutation", "variable", "mutator"), + Map.of("key", "after", "variable", "source")))))) + .modules(List.of(module)).logLevel(FeaturevisorLogLevel.FATAL)); + try { + Map context = new java.util.HashMap<>(Map.of("value", 1)); + for (int run = 1; run <= 2; run++) { + assertEquals(Map.of("before", 1, "duplicate", 1, "mutation", true, "after", 2), + f.getVariable("subject", context)); + assertEquals(run * 2, reads.get()); + assertEquals(Map.of("value", 1), context); + } + } finally { f.close(); } + } + + @Test void sourceGetterFallbacksStayOrdinaryAndNeverInheritTheOuterDefault() throws Exception { + List diagnostics = new ArrayList<>(); + List> members = new ArrayList<>(); + for (String key : List.of("broken", "missing", "gated", "zero", "empty", "nil", "alias")) + members.add(Map.of("key", key, "variable", key)); + members.add(Map.of("key", "duplicate", "variable", "broken")); + members.add(Map.of("key", "flag", "feature", "absent")); + members.add(Map.of("key", "variation", "feature", "absent", "variation", true)); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(data("4", Map.of(), Map.of(), Map.of( + "broken", Map.of("type", "json", "defaultValue", "false true"), + "gated", Map.of("type", "boolean", "defaultValue", true, "disabledValue", false, "conditions", List.of()), + "zero", Map.of("type", "integer", "defaultValue", 0), + "empty", Map.of("type", "string", "defaultValue", ""), + "nil", JSON.readValue("{\"type\":\"object\",\"defaultValue\":null}", Map.class), + "encoded", Map.of("type", "json", "defaultValue", "\"{oops\""), + "alias", Map.of("type", "json", "compose", "encoded"), + "subject", Map.of("compose", members)))) + .logLevel(FeaturevisorLogLevel.ERROR).onDiagnostic(diagnostics::add)); + Map expected = new java.util.HashMap<>(); + expected.put("broken", null); expected.put("duplicate", null); expected.put("missing", null); + expected.put("gated", false); expected.put("zero", 0); expected.put("empty", ""); + expected.put("nil", null); expected.put("alias", "{oops"); expected.put("flag", false); + expected.put("variation", null); + try { + for (int run = 1; run <= 2; run++) { + Evaluation result = f.evaluateVariable("subject", Map.of(), + new Featurevisor.OverrideOptions().defaultVariableValue("outer")); + assertNull(result.getError()); + assertEquals("variable_composed", result.getReason()); + assertEquals(expected, result.getVariableValue()); + assertEquals(run, diagnostics.stream().filter(d -> d.getMessage().equals("getVariable failed")).count()); + } + } finally { f.close(); } + } + + @Test void explicitNullRootsDifferFromAbsentRootsInBothSchemas() throws Exception { + for (String version : List.of("2", "4")) { + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(DatafileContent.fromJson("{\"schemaVersion\":\"" + version + "\",\"revision\":\"null\",\"segments\":{}," + + "\"features\":{\"present\":{\"conditions\":null,\"bucketBy\":\"userId\",\"traffic\":[{\"key\":\"all\",\"conditions\":\"*\",\"percentage\":100000}]}," + + "\"absent\":{\"bucketBy\":\"userId\",\"traffic\":[{\"key\":\"all\",\"conditions\":\"*\",\"percentage\":100000}]}}," + + "\"variables\":{\"present\":{\"conditions\":null,\"type\":\"string\",\"defaultValue\":\"wrong\",\"disabledValue\":\"blocked\"}," + + "\"mixed\":{\"conditions\":null,\"requiredFeatures\":[\"absent\"],\"type\":\"string\",\"defaultValue\":\"wrong\",\"disabledValue\":\"blocked\"}," + + "\"absent\":{\"type\":\"string\",\"defaultValue\":\"ok\"}}}")) + .logLevel(FeaturevisorLogLevel.FATAL)); + try { + for (int run = 0; run < 2; run++) { + assertEquals("conditions_unmet", f.evaluateFlag("present").getReason()); + assertTrue(f.isEnabled("absent")); + assertEquals("blocked", f.getVariable("present")); + assertEquals("blocked", f.getVariable("mixed")); + assertEquals("ok", f.getVariable("absent")); + } + assertTrue(JSON.valueToTree(f.getFeature("present")).get("conditions").isNull()); + assertFalse(JSON.valueToTree(f.getFeature("absent")).has("conditions")); + assertTrue(JSON.readValue(JSON.writeValueAsString(f.getFeature("present")), Feature.class).hasConditions()); + assertFalse(JSON.readValue(JSON.writeValueAsString(f.getFeature("absent")), Feature.class).hasConditions()); + GlobalVariable present = f.evaluateVariable("present").getVariable(); + GlobalVariable absent = f.evaluateVariable("absent").getVariable(); + assertTrue(JSON.valueToTree(present).get("conditions").isNull()); + assertFalse(JSON.valueToTree(absent).has("conditions")); + assertTrue(JSON.readValue(JSON.writeValueAsString(present), GlobalVariable.class).hasConditions()); + assertFalse(JSON.readValue(JSON.writeValueAsString(absent), GlobalVariable.class).hasConditions()); + } finally { f.close(); } + } + } + + @Test void notArraysAndGatedCompositionDefaultsMatchTheJsContract() throws Exception { + for (String operand : List.of("[]", "[null]")) { + for (String compose : List.of("\"source\"", "[{\"key\":\"value\",\"variable\":\"source\"}]")) { + for (boolean disabled : List.of(false, true)) { + AtomicInteger reads = new AtomicInteger(); + FeaturevisorModule module = new FeaturevisorModule("defaults").beforeEvaluation(options -> { + if (options.getDependencyOf() != null) reads.incrementAndGet(); + return options.defaultVariableValue("module fallback"); + }); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(DatafileContent.fromJson("{\"schemaVersion\":\"4\",\"revision\":\"not\",\"segments\":{\"segment\":{\"conditions\":\"*\"}},\"features\":{},\"variables\":{" + + "\"source\":{\"type\":\"string\",\"defaultValue\":\"source\"},\"subject\":{\"compose\":" + compose + + ",\"conditions\":{\"not\":" + operand + "}" + (disabled ? ",\"disabledValue\":false" : "") + "}}}")) + .modules(List.of(module)).logLevel(FeaturevisorLogLevel.FATAL)); + try { + Evaluation result = f.evaluateVariable("subject", Map.of(), new Featurevisor.OverrideOptions().defaultVariableValue("caller fallback")); + boolean matched = operand.equals("[null]"); + assertNull(result.getError()); + assertEquals(matched ? "variable_composed" : "conditions_unmet", result.getReason(), operand); + assertEquals(matched ? (compose.startsWith("[") ? Map.of("value", "source") : "source") : disabled ? false : null, + result.getVariableValue(), operand); + assertEquals(matched ? 1 : 0, reads.get()); + } finally { f.close(); } + } + } + } + } + + @Test void malformedNonArrayNotOperandsNeverEnableFlags() throws Exception { + for (String operand : List.of("null", "\"segment\"", "{}", "true", "1")) { + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(DatafileContent.fromJson("{\"schemaVersion\":\"4\",\"revision\":\"not\",\"segments\":{\"bad\":{\"conditions\":{\"not\":" + operand + "}}},\"variables\":{},\"features\":{" + + "\"subject\":{\"bucketBy\":\"userId\",\"conditions\":\"bad\",\"traffic\":[{\"key\":\"all\",\"conditions\":\"*\",\"percentage\":100000}]}}}")) + .logLevel(FeaturevisorLogLevel.FATAL)); + try { assertFalse(f.isEnabled("subject"), operand); } + finally { f.close(); } + } + } + + @Test void segmentReadsAfterACallbackUseTheAcceptedDatafile() throws Exception { + Featurevisor[] holder = {null}; + DatafileContent update = data("4", Map.of("audience", Map.of("conditions", "*")), Map.of(), Map.of()); + FeaturevisorModule module = new FeaturevisorModule("segments").afterEvaluation((result, options) -> { + if ("mutator".equals(options.getVariableKey())) assertTrue(holder[0].setDatafile(update)); + return result; + }); + holder[0] = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(data("4", Map.of("audience", Map.of("conditions", List.of())), Map.of(), Map.of( + "mutator", Map.of("type", "boolean", "defaultValue", true), + "subject", Map.of("type", "string", "defaultValue", "matched", "conditions", List.of( + Map.of("variable", "mutator", "operator", "equals", "value", true), "audience"))))) + .modules(List.of(module)).logLevel(FeaturevisorLogLevel.FATAL)); + try { assertEquals("matched", holder[0].getVariable("subject")); } + finally { holder[0].close(); } + } + + @Test void segmentDepthCountsTheTerminalWildcardAtTheExactBound() throws Exception { + for (int length : List.of(255, 256, 257)) { + Map segments = new java.util.LinkedHashMap<>(); + for (int index = 0; index < length; index++) + segments.put("s" + index, Map.of("conditions", index + 1 == length ? "*" : "s" + (index + 1))); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(data("4", segments, Map.of(), Map.of("subject", Map.of( + "type", "boolean", "defaultValue", true, "conditions", "s0")))) + .logLevel(FeaturevisorLogLevel.FATAL)); + try { + Evaluation result = f.evaluateVariable("subject"); + assertEquals(length > 256 ? "error" : "variable_default", result.getReason(), "depth " + length); + } finally { f.close(); } + } + } + + @Test void attributeTerminalFastPredicatesDoNotConsumeAnActiveHandoff() throws Exception { + Object attribute = Map.of("attribute", "country", "operator", "equals", "value", "nl"); + for (Object terminal : List.of(attribute, Map.of("and", List.of(attribute)))) { + for (int length : List.of(256, 257, 258)) { + Map segments = new java.util.LinkedHashMap<>(); + for (int index = 0; index < length; index++) + segments.put("s" + index, Map.of("conditions", index + 1 == length ? terminal : "s" + (index + 1))); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(data("4", segments, Map.of(), Map.of("subject", Map.of( + "type", "boolean", "defaultValue", true, "conditions", "s0")))) + .logLevel(FeaturevisorLogLevel.FATAL)); + try { + assertEquals(length > 257 ? "error" : "variable_default", + f.evaluateVariable("subject", Map.of("country", "nl")).getReason(), "attribute depth " + length); + } finally { f.close(); } + } + } + } + + @Test void mixedGetterAndSegmentDepthKeepsTerminalFastPredicateSemantics() throws Exception { + for (boolean attribute : List.of(false, true)) { + for (int length : List.of(254, 255, 256)) { + Map segments = new java.util.LinkedHashMap<>(); + segments.put("tail", Map.of("conditions", attribute + ? Map.of("attribute", "country", "operator", "equals", "value", "nl") : "*")); + for (int index = 0; index < length; index++) + segments.put("s" + index, Map.of("conditions", index + 1 == length + ? Map.of("variable", "source", "operator", "equals", "value", true) : "s" + (index + 1))); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(data("4", segments, Map.of(), Map.of( + "source", Map.of("type", "boolean", "defaultValue", true, "conditions", "tail"), + "subject", Map.of("type", "boolean", "defaultValue", true, "conditions", "s0")))) + .logLevel(FeaturevisorLogLevel.FATAL)); + try { + assertEquals(length > (attribute ? 255 : 254) ? "error" : "variable_default", + f.evaluateVariable("subject", Map.of("country", "nl")).getReason(), "mixed depth " + length + ", attribute=" + attribute); + } finally { f.close(); } + } + } + } + + @Test void featureStickySettersInBeforeHooksRefreshRootAndChildGetters() throws Exception { + for (boolean childMode : List.of(false, true)) { + Featurevisor[] holder = {null}; ChildInstance[] child = {null}; + FeaturevisorModule module = new FeaturevisorModule("sticky").beforeEvaluation(options -> { + Map sticky = Map.of("subject", Map.of("enabled", true, "variation", "after", "variables", Map.of("value", 9))); + if (childMode) child[0].setStickyFeatures(sticky, true); + else holder[0].setStickyFeatures(sticky, true); + return options; + }); + holder[0] = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(data("4", Map.of(), Map.of("subject", Map.of("bucketBy", "userId", "traffic", List.of(), + "variablesSchema", Map.of("value", Map.of("type", "integer", "defaultValue", 0)))), Map.of())) + .modules(List.of(module)).logLevel(FeaturevisorLogLevel.FATAL)); + child[0] = holder[0].spawn(); + try { + assertTrue(childMode ? child[0].isEnabled("subject") : holder[0].isEnabled("subject")); + assertEquals("after", childMode ? child[0].getVariation("subject") : holder[0].getVariation("subject")); + assertEquals(9, childMode ? child[0].getVariable("subject", "value") : holder[0].getVariable("subject", "value")); + } finally { child[0].close(); holder[0].close(); } + } + } + + @Test void moduleAddRemoveAndUnsubscribeInvalidateCompletedSourceMemos() throws Exception { + for (boolean unsubscribe : List.of(false, true)) { + Featurevisor[] holder = {null}; Runnable[] remove = {null}; AtomicInteger reads = new AtomicInteger(); + FeaturevisorModule projection = new FeaturevisorModule("projection").afterEvaluation((result, options) -> + "source".equals(options.getVariableKey()) ? result.variableValue(1) : result); + FeaturevisorModule module = new FeaturevisorModule("changes").afterEvaluation((result, options) -> { + if ("source".equals(options.getVariableKey())) reads.incrementAndGet(); + if ("add".equals(options.getVariableKey())) remove[0] = holder[0].addModule(projection); + if ("remove".equals(options.getVariableKey())) { + if (unsubscribe) remove[0].run(); else holder[0].removeModule("projection"); + } + return result; + }); + List members = new ArrayList<>(); + for (String key : List.of("before", "add", "during", "remove", "after")) + members.add(Map.of("key", key, "variable", key.equals("add") || key.equals("remove") ? key : "source")); + holder[0] = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(data("4", Map.of(), Map.of(), Map.of( + "source", Map.of("type", "integer", "defaultValue", 0), + "add", Map.of("type", "boolean", "defaultValue", true), + "remove", Map.of("type", "boolean", "defaultValue", true), + "subject", Map.of("compose", members)))) + .modules(List.of(module)).logLevel(FeaturevisorLogLevel.FATAL)); + try { + assertEquals(Map.of("before", 0, "add", true, "during", 1, "remove", true, "after", 0), holder[0].getVariable("subject")); + assertEquals(3, reads.get()); + } finally { holder[0].close(); } + } + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EvaluationSessionTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EvaluationSessionTest.java new file mode 100644 index 0000000..f9f194a --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EvaluationSessionTest.java @@ -0,0 +1,846 @@ +package com.featurevisor.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.AbstractList; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import org.junit.jupiter.api.Test; + +final class EvaluationSessionTest { + private final ObjectMapper mapper = new ObjectMapper(); + + private static final class VisitedMap extends HashMap { + private final AtomicInteger visits; + VisitedMap(AtomicInteger visits) { + this.visits = visits; + put("nested", Map.of("value", 1)); + } + @Override public java.util.Set> entrySet() { + visits.incrementAndGet(); + return super.entrySet(); + } + } + + @Test void unrelatedStickyEntriesAreNotVisitedOnPrerequisiteReads() throws Exception { + Map enabled = Map.of("bucketBy", "userId", "traffic", + List.of(Map.of("key", "all", "segments", "*", "percentage", 100000))); + Map rootFeature = new HashMap<>(enabled); + rootFeature.put("requiredFeatures", List.of("leaf", "leaf")); + for (String kind : List.of("features", "variables")) { + for (boolean childMode : new boolean[] {false, true}) { + for (int size : new int[] {0, 100, 1000, 5000}) { + AtomicInteger visits = new AtomicInteger(); + AtomicInteger leafReads = new AtomicInteger(); + Map sticky = new HashMap<>(); + for (int index = 0; index < size; index++) { + Object value = new VisitedMap(visits); + sticky.put("unrelated" + index, kind.equals("features") + ? Map.of("enabled", true, "variables", Map.of("payload", value)) : value); + } + sticky.put("leaf", kind.equals("features") ? Map.of("enabled", true) : 1); + FeaturevisorModule module = new FeaturevisorModule("count").beforeEvaluation(options -> { + if ("leaf".equals(options.getFeatureKey()) || "leaf".equals(options.getVariableKey())) { + leafReads.incrementAndGet(); + } + return options; + }); + Featurevisor sdk = instance(kind.equals("features") + ? Map.of("root", rootFeature, "leaf", enabled) : Map.of(), + kind.equals("variables") ? Map.of("root", variable(1, + List.of(requirement("leaf", 1), requirement("leaf", 1))), "leaf", variable(0, null)) : Map.of(), + module); + ChildInstance child = null; + try { + if (!childMode) { + if (kind.equals("features")) sdk.setStickyFeatures(sticky, true); + else sdk.setStickyVariables(sticky, true); + } + child = childMode ? sdk.spawn(Map.of(), kind.equals("features") + ? new Featurevisor.SpawnOptions().stickyFeatures(sticky) + : new Featurevisor.SpawnOptions().stickyVariables(sticky)) : null; + visits.set(0); // Handoff and event generation are outside the read. + if (kind.equals("features")) { + assertTrue(childMode ? child.isEnabled("root") : sdk.isEnabled("root")); + } else { + assertEquals(1, childMode ? child.getVariable("root") : sdk.getVariable("root")); + } + assertEquals(0, visits.get(), kind + ", child=" + childMode + ", size=" + size); + assertEquals(1, leafReads.get(), kind + ", child=" + childMode + ", size=" + size); + } finally { + if (child != null) child.close(); + sdk.close(); + } + } + } + } + } + + @Test void stickyScalingBenchmarkForSerialRuns() throws Exception { + org.junit.jupiter.api.Assumptions.assumeTrue("1".equals(System.getenv("FEATUREVISOR_STICKY_BENCHMARK"))); + int warmup = benchmarkCount("FEATUREVISOR_STICKY_WARMUP", 5000); + int iterations = benchmarkCount("FEATUREVISOR_STICKY_ITERATIONS", 10000); + System.out.println("kind,size,instance,nanoseconds_per_evaluation"); + Map enabled = Map.of("bucketBy", "userId", "traffic", + List.of(Map.of("key", "all", "segments", "*", "percentage", 100000))); + for (String kind : List.of("features", "variables")) { + for (int size : new int[] {0, 100, 1000, 5000}) { + Map sticky = exportedSticky(kind, size, enabled); + Map root = variable(1, kind.equals("features") ? null + : List.of(requirement("leaf", 1), requirement("leaf", 1))); + if (kind.equals("features")) root.put("requiredFeatures", List.of("leaf", "leaf")); + for (boolean childMode : new boolean[] {false, true}) { + Featurevisor sdk = instance(kind.equals("features") ? Map.of("leaf", enabled) : Map.of(), + kind.equals("features") ? Map.of("root", root) + : Map.of("root", root, "leaf", variable(0, null))); + ChildInstance child = null; + try { + if (!childMode) { + if (kind.equals("features")) sdk.setStickyFeatures(sticky, true); + else sdk.setStickyVariables(sticky, true); + } + child = childMode ? sdk.spawn(Map.of(), kind.equals("features") + ? new Featurevisor.SpawnOptions().stickyFeatures(sticky) + : new Featurevisor.SpawnOptions().stickyVariables(sticky)) : null; + assertEquals(1, childMode ? child.getVariable("root") : sdk.getVariable("root")); + for (int index = 0; index < warmup; index++) { + if (childMode) child.getVariable("root"); else sdk.getVariable("root"); + } + long start = System.nanoTime(); + for (int index = 0; index < iterations; index++) { + if (childMode) child.getVariable("root"); else sdk.getVariable("root"); + } + System.out.println(kind + "," + size + "," + (childMode ? "child" : "primary") + + "," + (System.nanoTime() - start) / iterations); + } finally { + if (child != null) child.close(); + sdk.close(); + } + } + } + } + } + + @Test void exportedStickyCorpusWorksForPrimaryAndChild() throws Exception { + Map enabled = Map.of("bucketBy", "userId", "traffic", + List.of(Map.of("key", "all", "segments", "*", "percentage", 100000))); + for (String kind : List.of("features", "variables")) { + Map sticky = exportedSticky(kind, 100, enabled); + assertEquals(101, sticky.size()); + Map root = variable(1, kind.equals("features") ? null + : List.of(requirement("leaf", 1), requirement("leaf", 1))); + if (kind.equals("features")) root.put("requiredFeatures", List.of("leaf", "leaf")); + Featurevisor sdk = instance(kind.equals("features") ? Map.of("leaf", enabled) : Map.of(), + kind.equals("features") ? Map.of("root", root) : Map.of("root", root, "leaf", variable(0, null))); + ChildInstance child = null; + try { + if (kind.equals("features")) sdk.setStickyFeatures(sticky, true); + else sdk.setStickyVariables(sticky, true); + assertEquals(1, sdk.getVariable("root")); + child = sdk.spawn(Map.of(), kind.equals("features") + ? new Featurevisor.SpawnOptions().stickyFeatures(sticky) + : new Featurevisor.SpawnOptions().stickyVariables(sticky)); + assertEquals(1, child.getVariable("root")); + } finally { + if (child != null) child.close(); + sdk.close(); + } + } + } + + private Map exportedSticky(String kind, int size, Map enabled) throws Exception { + Map features = new HashMap<>(); + Map variables = new HashMap<>(); + for (int index = 0; index < size; index++) { + if (kind.equals("features")) features.put("unrelated" + index, enabled); + else variables.put("unrelated" + index, variable(index, null)); + } + if (kind.equals("features")) features.put("leaf", enabled); + else variables.put("leaf", variable(1, null)); + Featurevisor exporter = instance(features, variables); + try { + if (kind.equals("features")) { + // Java exports typed feature objects; convert once at handoff. + @SuppressWarnings("unchecked") Map converted = + mapper.convertValue(exporter.getFeatureEvaluations(Map.of("userId", "bench")).getValue(), Map.class); + return converted; + } + return exporter.getVariableEvaluations(); + } finally { + exporter.close(); + } + } + + private static int benchmarkCount(String name, int fallback) { + String raw = System.getenv(name); + if (raw == null) return fallback; + try { + int value = Integer.parseInt(raw); + if (value > 0) return value; + } catch (NumberFormatException ignored) { + // Report the same invalid setting for nonnumeric and nonpositive input. + } + throw new IllegalArgumentException(name + " must be a positive integer"); + } + + private Featurevisor instance(Map features, Map variables, FeaturevisorModule... modules) throws Exception { + Map datafile = new LinkedHashMap<>(); + datafile.put("schemaVersion", "2"); + datafile.put("revision", "session"); + datafile.put("segments", Map.of()); + datafile.put("features", features); + datafile.put("variables", variables); + return Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(DatafileContent.fromJson(mapper.writeValueAsString(datafile))) + .modules(List.of(modules)).logLevel(FeaturevisorLogLevel.FATAL)); + } + + private Map variable(Object value, Object requirements) { + Map result = new HashMap<>(); + result.put("type", "integer"); + result.put("defaultValue", value); + if (requirements != null) result.put("conditions", requirements); + return result; + } + + private Map requirement(String key, Object value) { + return Map.of("variable", key, "operator", "equals", "value", value); + } + + @Test void variableAndFeatureDiamondsStayBoundedAndExposeImmediateParent() throws Exception { + Map variables = new LinkedHashMap<>(); + variables.put("leaf", variable(1, null)); + String previous = "leaf"; + for (int index = 0; index < 18; index++) { + String next = "level" + index; + variables.put(next, variable(1, List.of(requirement(previous, 1), requirement(previous, 1)))); + previous = next; + } + List parents = new ArrayList<>(); + AtomicInteger reads = new AtomicInteger(); + FeaturevisorModule module = new FeaturevisorModule("observe").beforeEvaluation(options -> { + reads.incrementAndGet(); + parents.add(options.getDependencyOf()); + return options; + }); + Featurevisor sdk = instance(Map.of(), variables, module); + assertEquals(1, sdk.getVariable(previous)); + assertEquals(19, reads.get()); + assertNull(parents.get(0)); + assertEquals(new DependencyOf(Evaluation.TYPE_VARIABLE, null, "level17"), parents.get(1)); + assertEquals(new DependencyOf(Evaluation.TYPE_VARIABLE, null, "level0"), parents.get(18)); + assertEquals(1, sdk.getVariable(previous)); + assertEquals(38, reads.get()); + + Map features = new LinkedHashMap<>(); + Map enabled = Map.of("bucketBy", "userId", "traffic", List.of(Map.of("key", "all", "segments", "*", "percentage", 100000))); + features.put("leaf", enabled); + previous = "leaf"; + for (int index = 0; index < 18; index++) { + String next = "level" + index; + Map feature = new HashMap<>(enabled); + feature.put("requiredFeatures", List.of(previous, previous)); + features.put(next, feature); + previous = next; + } + reads.set(0); + assertTrue(instance(features, Map.of(), module).isEnabled(previous)); + assertEquals(19, reads.get()); + } + + @Test void effectiveContextAndIdentityKeepSeparateDependencyReads() throws Exception { + Map variables = new HashMap<>(); + variables.put("leaf", variable(0, null)); + variables.put("a", variable(1, List.of(requirement("leaf", 1)))); + variables.put("b", variable(1, List.of(requirement("leaf", 2)))); + variables.put("root", variable(1, List.of(requirement("a", 1), requirement("b", 1)))); + AtomicInteger leaves = new AtomicInteger(); + FeaturevisorModule module = new FeaturevisorModule("contexts") + .beforeEvaluation(options -> { + if (!"a".equals(options.getVariableKey()) && !"b".equals(options.getVariableKey())) return options; + Map context = new HashMap<>(options.getContext()); + context.put("number", "a".equals(options.getVariableKey()) ? 1 : 2); + return options.copy().context(context); + }) + .afterEvaluation((evaluation, options) -> { + if ("leaf".equals(evaluation.getVariableKey())) { + leaves.incrementAndGet(); + evaluation.setVariableValue(options.getContext().get("number")); + } + return evaluation; + }); + assertEquals(1, instance(Map.of(), variables, module).getVariable("root")); + assertEquals(2, leaves.get()); + } + + @Test void twoNodePerLevelGraphStaysBoundedByEdges() throws Exception { + Map variables = new HashMap<>(); + variables.put("leafA", variable(1, null)); + variables.put("leafB", variable(1, null)); + String left = "leafA"; + String right = "leafB"; + for (int index = 0; index < 18; index++) { + String nextLeft = "a" + index; + String nextRight = "b" + index; + List requirements = List.of(requirement(left, 1), requirement(right, 1)); + variables.put(nextLeft, variable(1, requirements)); + variables.put(nextRight, variable(1, requirements)); + left = nextLeft; + right = nextRight; + } + variables.put("root", variable(1, List.of(requirement(left, 1), requirement(right, 1)))); + AtomicInteger before = new AtomicInteger(); + AtomicInteger after = new AtomicInteger(); + FeaturevisorModule module = new FeaturevisorModule("bounded") + .beforeEvaluation(options -> { before.incrementAndGet(); return options; }) + .afterEvaluation((evaluation, options) -> { after.incrementAndGet(); return evaluation; }); + assertEquals(1, instance(Map.of(), variables, module).getVariable("root")); + assertEquals(75, before.get()); + assertEquals(75, after.get()); + } + + @Test void rootHookContextReplacementIsInheritedWithoutInstanceRemerge() throws Exception { + Map variables = Map.of( + "leaf", variable(0, null), + "root", variable(1, List.of(requirement("leaf", 1)))); + Map datafile = Map.of("schemaVersion", "2", "revision", "context", "segments", Map.of(), + "features", Map.of(), "variables", variables); + FeaturevisorModule module = new FeaturevisorModule("replace-context") + .beforeEvaluation(options -> "root".equals(options.getVariableKey()) ? options.copy().context(Map.of()) : options) + .afterEvaluation((evaluation, options) -> { + if ("leaf".equals(evaluation.getVariableKey())) { + evaluation.setVariableValue(options.getContext().containsKey("country") ? 2 : 1); + } + return evaluation; + }); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(DatafileContent.fromJson(mapper.writeValueAsString(datafile))) + .context(Map.of("country", "de")).modules(List.of(module)).logLevel(FeaturevisorLogLevel.FATAL)); + assertEquals(1, sdk.getVariable("root")); + } + + @Test void knownStickyFeatureMapRetainsDiamondMemo() throws Exception { + Map variables = Map.of( + "leaf", variable(1, null), + "root", variable(1, List.of(requirement("leaf", 1), requirement("leaf", 1)))); + AtomicInteger reads = new AtomicInteger(); + FeaturevisorModule module = new FeaturevisorModule("count").beforeEvaluation(options -> { + if ("leaf".equals(options.getVariableKey())) reads.incrementAndGet(); + return options; + }); + Featurevisor sdk = instance(Map.of(), variables, module); + sdk.setStickyFeatures(Map.of("unrelated", Map.of("enabled", true, "variables", Map.of("nested", Map.of("number", 1))))); + assertEquals(1, sdk.getVariable("root")); + assertEquals(1, reads.get()); + } + + @Test void requesterAwareMemoSeparatesHookResultsButSharesRepeatedEdges() throws Exception { + Map variables = new HashMap<>(); + variables.put("leaf", variable(0, null)); + variables.put("a", variable(1, List.of(requirement("leaf", 1), requirement("leaf", 1)))); + variables.put("b", variable(1, List.of(requirement("leaf", 2), requirement("leaf", 2)))); + variables.put("root", variable(1, List.of(requirement("a", 1), requirement("b", 1)))); + AtomicInteger reads = new AtomicInteger(); + FeaturevisorModule module = new FeaturevisorModule("requester").afterEvaluation((evaluation, options) -> { + if ("leaf".equals(evaluation.getVariableKey())) { + reads.incrementAndGet(); + evaluation.setVariableValue("a".equals(options.getDependencyOf().getVariableKey()) ? 1 : 2); + } + return evaluation; + }); + assertEquals(1, instance(Map.of(), variables, module).getVariable("root")); + assertEquals(2, reads.get()); + } + + @Test void moduleMetadataDistinguishesFlagVariationFeatureAndGlobalVariables() throws Exception { + Map source = Map.of( + "bucketBy", "userId", + "variations", List.of(Map.of("value", "beta", "variables", Map.of("count", 1))), + "variablesSchema", Map.of("count", Map.of("type", "integer", "defaultValue", 1)), + "traffic", List.of(Map.of("key", "all", "segments", "*", "percentage", 100000, "variation", "beta"))); + Map root = variable(1, List.of( + Map.of("feature", "source", "variable", "count", "operator", "equals", "value", 1), + requirement("global", 1))); + root.put("requiredFeatures", List.of(Map.of("feature", "source", "enabled", true, "variation", "beta"))); + List kinds = new ArrayList<>(); + List parents = new ArrayList<>(); + FeaturevisorModule module = new FeaturevisorModule("metadata").beforeEvaluation(options -> { + kinds.add(DependencyOf.from(options)); + parents.add(options.getDependencyOf()); + return options; + }); + assertEquals(1, instance(Map.of("source", source), Map.of("global", variable(1, null), "root", root), module).getVariable("root")); + assertNull(parents.get(0)); + assertEquals(5, kinds.size()); + assertTrue(kinds.subList(1, kinds.size()).containsAll(List.of( + new DependencyOf(Evaluation.TYPE_FLAG, "source", null), + new DependencyOf(Evaluation.TYPE_VARIATION, "source", null), + new DependencyOf(Evaluation.TYPE_VARIABLE, "source", "count"), + new DependencyOf(Evaluation.TYPE_VARIABLE, null, "global")))); + assertTrue(parents.subList(1, parents.size()).stream().allMatch(value -> + new DependencyOf(Evaluation.TYPE_VARIABLE, null, "root").equals(value))); + } + + @Test void replacementHooksCannotEraseRequesterAndRedirectedRootStillCycles() throws Exception { + Map variables = Map.of( + "leaf", variable(1, null), + "root", variable(1, List.of(requirement("leaf", 1)))); + List seen = new ArrayList<>(); + FeaturevisorModule replace = new FeaturevisorModule("replace").beforeEvaluation(options -> + options.copy().dependencyOf(null)); + FeaturevisorModule observe = new FeaturevisorModule("observe").beforeEvaluation(options -> { + if ("leaf".equals(options.getVariableKey())) seen.add(options.getDependencyOf()); + return options; + }); + assertEquals(1, instance(Map.of(), variables, replace, observe).getVariable("root")); + assertEquals(List.of(new DependencyOf(Evaluation.TYPE_VARIABLE, null, "root")), seen); + + AtomicInteger originalReads = new AtomicInteger(); + FeaturevisorModule redirect = new FeaturevisorModule("redirect").beforeEvaluation(options -> { + if ("a".equals(options.getVariableKey())) { + originalReads.incrementAndGet(); + return options.copy().variableKey("b"); + } + return options; + }); + Featurevisor cyclic = instance(Map.of(), Map.of( + "a", variable(1, null), + "b", variable(1, List.of(requirement("a", 1))) + ), redirect); + assertEquals(Evaluation.REASON_ERROR, cyclic.evaluateVariable("a").getReason()); + assertEquals(1, originalReads.get()); + } + + @Test void nestedInvalidRequirementPathCannotBecomeNullGetter() throws Exception { + Featurevisor sdk = instance(Map.of(), Map.of( + "leaf", Map.of("type", "object", "defaultValue", Map.of()), + "bad", variable(1, List.of(Map.of("variable", "leaf", "path", List.of("__proto__"), "operator", "notExists"))), + "root", variable(1, List.of(Map.of("variable", "bad", "operator", "isNull"))) + )); + assertEquals(Evaluation.REASON_ERROR, sdk.evaluateVariable("root").getReason()); + } + + @Test void cyclesPropagateThroughNegationButHandledGetterErrorsCanMatch() throws Exception { + AtomicInteger brokenReads = new AtomicInteger(); + AtomicInteger failedReads = new AtomicInteger(); + FeaturevisorModule module = new FeaturevisorModule("errors").afterEvaluation((evaluation, options) -> { + if (Evaluation.TYPE_FLAG.equals(evaluation.getType()) && "broken".equals(evaluation.getFeatureKey())) { + brokenReads.incrementAndGet(); + evaluation.reason(Evaluation.REASON_ERROR).enabled(false); + } + if ("failed".equals(evaluation.getVariableKey())) { + failedReads.incrementAndGet(); + evaluation.reason(Evaluation.REASON_ERROR).variableValue(null); + } + return evaluation; + }); + Featurevisor sdk = instance(Map.of( + "gate", Map.of("bucketBy", "userId", "conditions", List.of(Map.of("not", List.of(Map.of("variable", "source", "operator", "notNull")))), "traffic", List.of()), + "broken", Map.of("bucketBy", "userId", "traffic", List.of()), + "disabledGate", Map.of("bucketBy", "userId", "requiredFeatures", List.of(Map.of("feature", "broken", "enabled", false), Map.of("not", List.of("broken"))), "traffic", List.of(Map.of("key", "all", "segments", "*", "percentage", 100000))) + ), Map.of( + "source", variable(1, List.of(Map.of("variable", "source", "operator", "equals", "value", 1))), + "nullGate", variable(1, List.of(Map.of("variable", "failed", "operator", "isNull"), Map.of("variable", "failed", "operator", "isNull"))), + "failed", variable(1, null) + ), module); + assertEquals(Evaluation.REASON_ERROR, sdk.evaluateVariable("source").getReason()); + assertTrue(sdk.isEnabled("disabledGate")); + assertEquals(1, sdk.getVariable("nullGate")); + assertEquals(1, brokenReads.get()); + assertEquals(1, failedReads.get()); + } + + @Test void reentrantAndConcurrentPublicCallsUseFreshSessions() throws Exception { + Map variables = Map.of( + "leaf", variable(1, null), + "root", variable(1, List.of(requirement("leaf", 1), requirement("leaf", 1)))); + Featurevisor[] holder = new Featurevisor[1]; + AtomicBoolean nested = new AtomicBoolean(); + AtomicInteger leafReads = new AtomicInteger(); + List rootParents = java.util.Collections.synchronizedList(new ArrayList<>()); + FeaturevisorModule module = new FeaturevisorModule("reentrant").beforeEvaluation(options -> { + if ("root".equals(options.getVariableKey())) rootParents.add(options.getDependencyOf()); + if ("leaf".equals(options.getVariableKey())) { + leafReads.incrementAndGet(); + if (nested.compareAndSet(false, true)) { + try { holder[0].getVariable("root"); } + finally { nested.set(false); } + } + } + return options; + }); + holder[0] = instance(Map.of(), variables, module); + assertEquals(1, holder[0].getVariable("root")); + assertEquals(2, leafReads.get()); + assertEquals(2, rootParents.size()); + assertTrue(rootParents.stream().allMatch(value -> value == null)); + + ExecutorService pool = Executors.newFixedThreadPool(4); + try { + List> tasks = new ArrayList<>(); + for (int index = 0; index < 12; index++) tasks.add(() -> holder[0].getVariable("root")); + for (Future result : pool.invokeAll(tasks)) assertEquals(1, result.get()); + } finally { + pool.shutdownNow(); + } + } + + @Test void memoSnapshotDoesNotShareMutableValuesOrUnrelatedStates() { + EvaluationSession session = new EvaluationSession(); + EvaluateOptions options = new EvaluateOptions(Evaluation.TYPE_VARIABLE, null, "leaf") + .globalVariable(true).context(Map.of("nested", Map.of("key", 1))); + assertTrue(session.beginRoot(new DependencyOf(Evaluation.TYPE_VARIABLE, null, "root"))); + AtomicInteger calls = new AtomicInteger(); + @SuppressWarnings("unchecked") Map first = (Map) session.read(options, () -> { + calls.incrementAndGet(); + return new HashMap<>(Map.of("items", new ArrayList<>(List.of(1)))); + }); + @SuppressWarnings("unchecked") List firstItems = (List) first.get("items"); + firstItems.add(2); + @SuppressWarnings("unchecked") Map second = (Map) session.read(options, () -> { + calls.incrementAndGet(); + return null; + }); + assertEquals(List.of(1), second.get("items")); + assertEquals(1, calls.get()); + options.setContext(Map.of("nested", Map.of("key", 2))); + session.read(options, () -> { calls.incrementAndGet(); return null; }); + assertEquals(2, calls.get()); + session.endRoot(); + } + + @Test void snapshotDistinguishesDatesTextBooleansNumbersAndSkipsUnknownHostValues() { + EvaluationSession session = new EvaluationSession(); + EvaluateOptions options = new EvaluateOptions(Evaluation.TYPE_VARIABLE, null, "leaf").globalVariable(true); + assertTrue(session.beginRoot(new DependencyOf(Evaluation.TYPE_VARIABLE, null, "root"))); + AtomicInteger calls = new AtomicInteger(); + options.setContext(Map.of("at", new Date(0))); + session.read(options, () -> calls.incrementAndGet()); + session.read(options, () -> calls.incrementAndGet()); + assertEquals(1, calls.get()); + options.setContext(Map.of("at", "1970-01-01T00:00:00Z")); + session.read(options, () -> calls.incrementAndGet()); + options.setContext(Map.of("at", true)); + session.read(options, () -> calls.incrementAndGet()); + options.setContext(Map.of("at", 1)); + session.read(options, () -> calls.incrementAndGet()); + assertEquals(4, calls.get()); + options.setContext(Map.of("at", new Object())); + session.read(options, () -> calls.incrementAndGet()); + session.read(options, () -> calls.incrementAndGet()); + assertEquals(6, calls.get()); + session.endRoot(); + } + + @Test void registrationRestorationDuringReadInvalidatesMemo() { + EvaluationSession session = new EvaluationSession(); + ModulesManager manager = new ModulesManager(new ModulesManager.ModulesManagerOptions()); + EvaluateOptions options = new EvaluateOptions(Evaluation.TYPE_VARIABLE, null, "leaf") + .globalVariable(true).context(Map.of()).modulesManager(manager); + assertTrue(session.beginRoot(new DependencyOf(Evaluation.TYPE_VARIABLE, null, "root"))); + AtomicInteger reads = new AtomicInteger(); + session.read(options, () -> { + reads.incrementAndGet(); + FeaturevisorModule temporary = new FeaturevisorModule("temporary"); + manager.add(temporary); + manager.remove(temporary); + return 1; + }); + session.read(options, () -> { reads.incrementAndGet(); return 1; }); + assertEquals(2, reads.get()); + session.endRoot(); + } + + @Test void childStickyRestorationStartsFreshRequests() throws Exception { + Map variables = Map.of( + "leaf", variable(0, null), + "root", variable(1, List.of(requirement("leaf", 1), requirement("leaf", 1)))); + Featurevisor sdk = instance(Map.of(), variables); + sdk.setStickyVariables(Map.of("leaf", 0)); + ChildInstance child = sdk.spawn(Map.of(), new Featurevisor.SpawnOptions().stickyVariables(Map.of("leaf", 1))); + assertNull(sdk.getVariable("root")); + assertEquals(1, child.getVariable("root")); + child.setStickyVariables(Map.of("leaf", 0), true); + assertNull(child.getVariable("root")); + child.setStickyVariables(Map.of("leaf", 1), true); + assertEquals(1, child.getVariable("root")); + } + + @Test void rootAndChildStickySetterRefreshPrerequisitesIncludingRestoration() throws Exception { + for (boolean childMode : new boolean[] {false, true}) { + Map variables = Map.of( + "leaf", variable(0, null), "mutator", variable(1, null), + "root", variable(1, List.of(requirement("leaf", 1), requirement("mutator", 1), requirement("leaf", 2)))); + Featurevisor[] sdkHolder = new Featurevisor[1]; + ChildInstance[] childHolder = new ChildInstance[1]; + AtomicInteger reads = new AtomicInteger(); + FeaturevisorModule module = new FeaturevisorModule("sticky-transition").afterEvaluation((evaluation, options) -> { + if ("leaf".equals(evaluation.getVariableKey())) reads.incrementAndGet(); + if ("mutator".equals(evaluation.getVariableKey())) { + if (childMode) childHolder[0].setStickyVariables(Map.of("leaf", 2)); + else sdkHolder[0].setStickyVariables(Map.of("leaf", 2)); + } + return evaluation; + }); + Featurevisor sdk = instance(Map.of(), variables, module); + sdkHolder[0] = sdk; + sdk.setStickyVariables(Map.of("leaf", 1)); + ChildInstance child = sdk.spawn(Map.of(), new Featurevisor.SpawnOptions().stickyVariables(Map.of("leaf", 1))); + childHolder[0] = child; + assertEquals(1, childMode ? child.getVariable("root") : sdk.getVariable("root")); + assertEquals(2, reads.get()); + } + + Map variables = Map.of( + "leaf", variable(0, null), "mutator", variable(1, null), + "root", variable(1, List.of(requirement("leaf", 1), requirement("mutator", 1), requirement("leaf", 1)))); + Featurevisor[] sdkHolder = new Featurevisor[1]; + AtomicInteger reads = new AtomicInteger(); + FeaturevisorModule module = new FeaturevisorModule("restore").afterEvaluation((evaluation, options) -> { + if ("leaf".equals(evaluation.getVariableKey())) reads.incrementAndGet(); + if ("mutator".equals(evaluation.getVariableKey())) { + sdkHolder[0].setStickyVariables(Map.of("leaf", 2)); + sdkHolder[0].setStickyVariables(Map.of("leaf", 1)); + } + return evaluation; + }); + Featurevisor sdk = instance(Map.of(), variables, module); + sdkHolder[0] = sdk; + sdk.setStickyVariables(Map.of("leaf", 1)); + assertEquals(1, sdk.getVariable("root")); + assertEquals(2, reads.get()); + } + + @Test void globalStickySetterInBeforeHookIsVisibleToGetter() throws Exception { + Featurevisor[] sdkHolder = new Featurevisor[1]; + FeaturevisorModule module = new FeaturevisorModule("before-sticky").beforeEvaluation(options -> { + if ("root".equals(options.getVariableKey())) sdkHolder[0].setStickyVariables(Map.of("root", 2)); + return options; + }); + Featurevisor sdk = instance(Map.of(), Map.of("root", variable(1, null)), module); + sdkHolder[0] = sdk; + assertEquals(2, sdk.getVariable("root")); + } + + @Test void stickySetterRefreshesEveryDependencyKindForRootAndChild() throws Exception { + Map enabled = Map.of( + "bucketBy", "userId", + "traffic", List.of(Map.of("key", "all", "segments", "*", "percentage", 100000))); + for (String kind : List.of("flag", "variation", "featureVariable", "globalVariable")) { + for (boolean childMode : new boolean[] {false, true}) { + Map features = new HashMap<>(); + Map variables = new HashMap<>(); + Object first; + Object last; + if ("flag".equals(kind) || "variation".equals(kind)) { + Map leaf = new HashMap<>(enabled); + leaf.put("variations", List.of(Map.of("value", "beta"), Map.of("value", "gamma"))); + features.put("leaf", leaf); + features.put("mutator", enabled); + if ("flag".equals(kind)) { + first = Map.of("feature", "leaf", "enabled", true); + last = Map.of("feature", "leaf", "enabled", false); + } else { + first = Map.of("feature", "leaf", "variation", "beta"); + last = Map.of("feature", "leaf", "variation", "gamma"); + } + Map root = new HashMap<>(enabled); + root.put("requiredFeatures", List.of(first, "mutator", last)); + features.put("root", root); + } else { + variables.put("mutator", variable(1, null)); + if ("featureVariable".equals(kind)) { + Map leaf = new HashMap<>(enabled); + leaf.put("variablesSchema", Map.of("count", Map.of("type", "integer", "defaultValue", 0))); + features.put("leaf", leaf); + first = Map.of("feature", "leaf", "variable", "count", "operator", "equals", "value", 1); + last = Map.of("feature", "leaf", "variable", "count", "operator", "equals", "value", 2); + } else { + variables.put("leaf", variable(0, null)); + first = requirement("leaf", 1); + last = requirement("leaf", 2); + } + variables.put("root", variable(1, List.of(first, requirement("mutator", 1), last))); + } + Map initialFeature = Map.of("enabled", true, "variation", "beta", "variables", Map.of("count", 1)); + Map changedFeature = Map.of("enabled", !"flag".equals(kind), "variation", "gamma", "variables", Map.of("count", 2)); + Featurevisor[] sdkHolder = new Featurevisor[1]; + ChildInstance[] childHolder = new ChildInstance[1]; + AtomicInteger reads = new AtomicInteger(); + FeaturevisorModule module = new FeaturevisorModule("kind-transition").afterEvaluation((evaluation, options) -> { + if ("leaf".equals(evaluation.getFeatureKey()) || + ("globalVariable".equals(kind) && "leaf".equals(evaluation.getVariableKey()))) { + if (("flag".equals(kind) && Evaluation.TYPE_FLAG.equals(evaluation.getType())) + || ("variation".equals(kind) && Evaluation.TYPE_VARIATION.equals(evaluation.getType())) + || (("featureVariable".equals(kind) || "globalVariable".equals(kind)) + && Evaluation.TYPE_VARIABLE.equals(evaluation.getType()))) reads.incrementAndGet(); + } + if ("mutator".equals(evaluation.getFeatureKey()) || "mutator".equals(evaluation.getVariableKey())) { + if (childMode) { + childHolder[0].setStickyFeatures(Map.of("leaf", changedFeature)); + childHolder[0].setStickyVariables(Map.of("leaf", 2)); + } else { + sdkHolder[0].setStickyFeatures(Map.of("leaf", changedFeature)); + sdkHolder[0].setStickyVariables(Map.of("leaf", 2)); + } + } + return evaluation; + }); + Featurevisor sdk = instance(features, variables, module); + sdkHolder[0] = sdk; + sdk.setStickyFeatures(Map.of("leaf", initialFeature)); + sdk.setStickyVariables(Map.of("leaf", 1)); + ChildInstance child = sdk.spawn(Map.of(), new Featurevisor.SpawnOptions() + .stickyFeatures(Map.of("leaf", initialFeature)).stickyVariables(Map.of("leaf", 1))); + childHolder[0] = child; + if ("flag".equals(kind) || "variation".equals(kind)) { + assertTrue(childMode ? child.isEnabled("root") : sdk.isEnabled("root"), kind + ", child=" + childMode); + } else { + assertEquals(1, childMode ? child.getVariable("root") : sdk.getVariable("root"), kind + ", child=" + childMode); + } + assertEquals(2, reads.get(), kind + ", child=" + childMode); + } + } + } + + @Test void redirectedDependencyMayEqualAncestorWithoutAnotherRead() throws Exception { + FeaturevisorModule module = new FeaturevisorModule("redirect-to-sticky").beforeEvaluation(options -> { + if (!"leaf".equals(options.getVariableKey())) return options; + EvaluateOptions redirected = options.copy(); + redirected.setVariableKey("root"); + redirected.setStickyVariables(Map.of("root", 1)); + return redirected; + }); + Featurevisor sdk = instance(Map.of(), Map.of( + "root", variable(1, List.of(requirement("leaf", 1))), "leaf", variable(1, null)), module); + assertEquals(1, sdk.getVariable("root")); + } + + @Test void retainedModuleOptionsDoNotKeepStickySourceOrActiveRequest() throws Exception { + EvaluateOptions[] retained = new EvaluateOptions[1]; + FeaturevisorModule module = new FeaturevisorModule("retain").beforeEvaluation(options -> { + if ("leaf".equals(options.getVariableKey())) retained[0] = options; + return options; + }); + Featurevisor sdk = instance(Map.of(), Map.of( + "root", variable(1, List.of(requirement("leaf", 1))), "leaf", variable(1, null)), module); + assertEquals(1, sdk.getVariable("root")); + assertEquals(new DependencyOf(Evaluation.TYPE_VARIABLE, null, "root"), retained[0].getDependencyOf()); + assertNull(retained[0].getStickySource()); + assertNull(retained[0].getSession().current()); + } + + private void assertPublicReadCount(Object contextValue, Object resultValue, int expectedReads) throws Exception { + AtomicInteger reads = new AtomicInteger(); + FeaturevisorModule module = new FeaturevisorModule("host-value").afterEvaluation((evaluation, options) -> { + if ("leaf".equals(evaluation.getVariableKey())) { + reads.incrementAndGet(); + evaluation.setVariableValue(resultValue); + } + return evaluation; + }); + Map required = Map.of("variable", "leaf", "operator", "notNull"); + Featurevisor sdk = instance(Map.of(), Map.of( + "root", variable(1, List.of(required, required)), "leaf", variable(1, null)), module); + assertEquals(1, sdk.getVariable("root", Map.of("host", contextValue))); + assertEquals(expectedReads, reads.get()); + assertSame(resultValue, sdk.getVariable("leaf", Map.of("host", contextValue))); + } + + private List unsupportedGraphs() { + Map mapCycle = new HashMap<>(); + mapCycle.put("nested", List.of(mapCycle)); + List listCycle = new ArrayList<>(); + listCycle.add(Map.of("nested", listCycle)); + Object[] arrayCycle = new Object[1]; + arrayCycle[0] = Map.of("nested", arrayCycle); + + Object deep = 1; + for (int level = 0; level < 1000; level++) deep = List.of(Map.of("nested", deep)); + Object shared = 1; + for (int level = 0; level < 20; level++) shared = List.of(shared, shared); + return List.of(mapCycle, listCycle, arrayCycle, deep, shared); + } + + @Test void publicGettersBypassMemoForCyclicAndOversizedContexts() throws Exception { + for (Object context : unsupportedGraphs()) assertPublicReadCount(context, 1, 2); + } + + @Test void publicGettersBypassMemoForCyclicAndOversizedResults() throws Exception { + for (Object result : unsupportedGraphs()) assertPublicReadCount(1, result, 2); + } + + private void assertPublicContextTransition(Object first, Object second, Function observe) throws Exception { + AtomicInteger reads = new AtomicInteger(); + FeaturevisorModule module = new FeaturevisorModule("context-transition").afterEvaluation((evaluation, options) -> { + if ("leaf".equals(evaluation.getVariableKey())) { + reads.incrementAndGet(); + evaluation.setVariableValue(observe.apply(options.getContext().get("host"))); + } + if ("mutator".equals(evaluation.getVariableKey())) options.getContext().put("host", second); + return evaluation; + }); + Featurevisor sdk = instance(Map.of(), Map.of( + "leaf", variable(0, null), "mutator", variable(1, null), + "root", variable(1, List.of(requirement("leaf", 1), requirement("mutator", 1), requirement("leaf", 2)))), module); + assertEquals(1, sdk.getVariable("root", Map.of("host", first))); + assertEquals(2, reads.get()); + } + + @Test void publicMemoDistinguishesNumericAndCollectionRepresentations() throws Exception { + assertPublicContextTransition(1, 1.0, value -> value instanceof Integer ? 1 : 2); + assertPublicContextTransition(0.0, -0.0, value -> Double.doubleToRawLongBits((Double) value) == 0 ? 1 : 2); + assertPublicContextTransition(0.0f, -0.0f, value -> Float.floatToRawIntBits((Float) value) == 0 ? 1 : 2); + assertPublicContextTransition(new BigDecimal("1"), new BigDecimal("1.0"), value -> ((BigDecimal) value).scale() == 0 ? 1 : 2); + assertPublicContextTransition(List.of(1), new Integer[] {1}, value -> value instanceof List ? 1 : 2); + assertPublicContextTransition(new Integer[] {1}, new int[] {1}, value -> value instanceof Integer[] ? 1 : 2); + } + + private static final class MutableNumber extends Number { + private int value = 1; + @Override public int intValue() { return value; } + @Override public long longValue() { return value; } + @Override public float floatValue() { return value; } + @Override public double doubleValue() { return value; } + @Override public String toString() { return Integer.toString(value); } + } + + @Test void publicMemoBypassesMutableHostNumbersInInputsAndResults() throws Exception { + for (Number value : List.of(new AtomicInteger(1), new MutableNumber())) { + assertPublicReadCount(Map.of("nested", List.of(value)), 1, 2); + assertPublicReadCount(1, value, 2); + assertPublicReadCount(1, Map.of("nested", List.of(value)), 2); + } + } + + @Test void unsupportedContextDoesNotInspectResultForMemoisation() throws Exception { + List opaqueResult = new AbstractList() { + @Override public Object get(int index) { throw new AssertionError("Result must not be inspected"); } + @Override public int size() { throw new AssertionError("Result must not be inspected"); } + }; + assertPublicReadCount(new Object(), opaqueResult, 2); + } + + @Test void immutableNumbersAndSharedAcyclicInputsStillMemoise() throws Exception { + for (Number value : List.of((byte) 1, (short) 1, 1, 1L, 1.0f, 1.0, + new BigInteger("12345678901234567890"), new BigDecimal("1.00"))) { + assertPublicReadCount(value, value, 1); + } + Map shared = Map.of("value", 1); + assertPublicReadCount(List.of(shared, shared), 1, 1); + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EvaluationStateLifecycleTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EvaluationStateLifecycleTest.java new file mode 100644 index 0000000..879f607 --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EvaluationStateLifecycleTest.java @@ -0,0 +1,157 @@ +package com.featurevisor.sdk; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +final class EvaluationStateLifecycleTest { + private static final ObjectMapper JSON = new ObjectMapper(); + + private DatafileContent datafile(Map features, Map variables, Map segments) { + return JSON.convertValue(Map.of("schemaVersion", "4", "revision", "evaluation-state", + "features", features, "variables", variables, "segments", segments), DatafileContent.class); + } + + private InstanceEvaluationDataProvider provider(DatafileContent raw) { + return new InstanceEvaluationDataProvider(new InstanceEvaluationDataProvider.InstanceEvaluationDataProviderOptions() + .datafile(raw).diagnostics(DiagnosticReporter.createDiagnosticReporter( + new DiagnosticReporter.DiagnosticReporterOptions().handler((level, message, details) -> fail("index emitted a diagnostic"))))); + } + + @Test void retainedSelectorsAreNeverExtractedDuringCachedUpdates() { + for (int size : new int[]{8, 512}) { + Map segments = new HashMap<>(); + for (int index = 0; index < size; index++) segments.put("s" + index, + Map.of("conditions", "[{\"variable\":\"gate\",\"operator\":\"equals\",\"value\":true}]")); + DatafileContent raw = datafile(Map.of("subject", Map.of("hash", "subject", "conditions", "s0", "bucketBy", "userId", "traffic", List.of())), + Map.of("gate", Map.of("hash", "gate0", "type", "boolean", "defaultValue", true), + "alias", Map.of("hash", "alias", "type", "boolean", "compose", "gate")), segments); + InstanceEvaluationDataProvider current = provider(raw); + assertEquals(0, current.getCachedDependencyIndexCount()); + for (int revision = 1; revision <= 8; revision++) { + DatafileContent incoming = datafile(Map.of(), Map.of("gate", Map.of("hash", "gate" + revision, + "type", "boolean", "defaultValue", revision % 2 == 0)), Map.of()); + DatafileContent nextRaw = current.getDatafile(); + nextRaw.setVariables(new HashMap<>(nextRaw.getVariables())); + nextRaw.getVariables().put("gate", incoming.getVariables().get("gate")); + InstanceEvaluationDataProvider next = provider(nextRaw); + next.retainCaches(current, incoming); + FeaturevisorEventDetails event = Events.getParamsForProviderSetEvent(current, next, false); + assertEquals(Set.of("subject"), new HashSet<>((List) event.get("features"))); + assertEquals(Set.of("alias", "gate"), new HashSet<>((List) event.get("variables"))); + assertEquals(1, next.getDependencyIndex().extractedEntities, "size=" + size + ", revision=" + revision); + assertEquals(1, current.getCachedDependencyIndexCount()); + current = next; + } + current.clearCaches(); + assertEquals(0, current.getCachedDependencyIndexCount()); + } + } + + @Test void initialNotificationLeavesBothIndexesCold() { + InstanceEvaluationDataProvider empty = provider(datafile(Map.of(), Map.of(), Map.of())); + InstanceEvaluationDataProvider next = provider(datafile(Map.of(), + Map.of("gate", Map.of("hash", "gate", "type", "boolean", "defaultValue", true)), Map.of())); + assertEquals(List.of("gate"), Events.getParamsForProviderSetEvent(empty, next, true).get("variables")); + assertEquals(0, empty.getCachedDependencyIndexCount()); + assertEquals(0, next.getCachedDependencyIndexCount()); + } + + @Test void concurrentPartialWritersKeepAllEntitiesAndRetainedDiagnosticCaches() throws Exception { + java.util.concurrent.atomic.AtomicInteger diagnostics = new java.util.concurrent.atomic.AtomicInteger(); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(datafile(Map.of(), Map.of( + "source", Map.of("hash", "source", "type", "string", "defaultValue", "stable"), + "alias", Map.of("hash", "alias", "type", "string", "compose", "source"), + "broken", Map.of("hash", "broken", "type", "string", "defaultValue", "wrong", "conditions", "[broken")), Map.of())) + .logLevel(FeaturevisorLogLevel.ERROR).onDiagnostic(diagnostic -> { + if ("conditions_parse_error".equals(diagnostic.getCode())) diagnostics.incrementAndGet(); + })); + java.util.concurrent.ExecutorService pool = java.util.concurrent.Executors.newFixedThreadPool(4); + try { + assertNull(sdk.getVariable("broken")); + List> tasks = new ArrayList<>(); + for (int index = 0; index < 128; index++) { + final int key = index; + tasks.add(pool.submit(() -> { + if (key % 4 == 0) assertTrue(sdk.setDatafile(datafile(Map.of(), Map.of("v" + key, + Map.of("hash", "v" + key, "type", "integer", "defaultValue", key)), Map.of()))); + else { + assertEquals("stable", sdk.getVariable("alias")); + assertNull(sdk.getVariable("broken")); + } + })); + } + for (java.util.concurrent.Future task : tasks) task.get(30, java.util.concurrent.TimeUnit.SECONDS); + for (int index = 0; index < 128; index += 4) assertEquals(index, sdk.getVariable("v" + index)); + assertEquals(1, diagnostics.get()); + } finally { pool.shutdownNow(); sdk.close(); } + } + + @Test void oldAndNewGraphsRemainIndependentAndRemovedOwnerEdgesDisappear() { + DatafileContent before = datafile(Map.of(), Map.of( + "root", Map.of("hash", "root", "type", "boolean", "defaultValue", true), + "bridge", Map.of("hash", "bridge", "type", "boolean", "defaultValue", true), + "phantom", Map.of("hash", "phantom", "type", "boolean", "compose", "bridge")), Map.of()); + DatafileContent after = datafile(Map.of(), Map.of( + "root", Map.of("hash", "root2", "type", "boolean", "defaultValue", false), + "bridge", Map.of("hash", "bridge2", "type", "boolean", "compose", "root")), Map.of()); + Dependencies.Index old = Dependencies.create(before); + Dependencies.Index next = Dependencies.update(old, before, after); + Set features = new HashSet<>(), variables = new HashSet<>(Set.of("root")); + Dependencies.addChanges(features, variables, Set.of(), old); + Dependencies.addChanges(features, variables, Set.of(), next); + assertEquals(Set.of("root", "bridge"), variables); + assertEquals(Set.of("1:phantom"), old.dependents[1].get("bridge")); + assertNull(next.dependents[1].get("bridge")); + assertFalse(next.owners.containsKey("1:phantom")); + } + + @Test void currentPhaseModulesIncludeLateAdditionsAndExcludeRemovalsForFeatureAndGlobalRoots() { + for (boolean global : new boolean[]{false, true}) for (boolean removing : new boolean[]{false, true}) + for (boolean disableGate : new boolean[]{false, true}) { + List trace = new ArrayList<>(); + AtomicReference owner = new AtomicReference<>(); + FeaturevisorModule observer = new FeaturevisorModule("observer").beforeEvaluation(options -> { + trace.add("before:" + (options.isGlobalVariable() ? options.getVariableKey() : options.getFeatureKey())); + return options; + }).afterEvaluation((evaluation, options) -> { + trace.add("after:" + (options.isGlobalVariable() ? options.getVariableKey() : options.getFeatureKey())); + if (disableGate && "gate".equals(options.getFeatureKey())) evaluation.enabled(false); + return evaluation; + }); + Object conditions = Map.of("or", List.of(Map.of("feature", "broken"), Map.of("feature", "gate"))); + Map enabled = Map.of("bucketBy", "userId", "traffic", List.of( + Map.of("key", "all", "conditions", "*", "percentage", 100000))); + Map subject = new HashMap<>(enabled); + subject.put("conditions", conditions); + Map broken = new HashMap<>(enabled); + broken.put("conditions", "[broken"); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(datafile(Map.of("broken", broken, "gate", enabled, "subject", subject), + Map.of("subject", Map.of("type", "string", "defaultValue", "matched", "conditions", conditions)), Map.of())) + .logLevel(FeaturevisorLogLevel.ERROR).modules(removing ? List.of(observer) : List.of()) + .onDiagnostic(diagnostic -> { + if (!"conditions_parse_error".equals(diagnostic.getCode())) return; + if (removing) owner.get().removeModule("observer"); + else owner.get().addModule(observer); + })); + owner.set(sdk); + boolean blocked = disableGate && !removing; + Evaluation result = global ? sdk.evaluateVariable("subject") : sdk.evaluateFlag("subject", Map.of("userId", "u")); + if (global) assertEquals(blocked ? null : "matched", result.getVariableValue()); + else assertEquals(!blocked, result.getEnabled()); + if (blocked) assertEquals("conditions_unmet", result.getReason()); + assertEquals(removing ? List.of("before:subject", "before:broken") + : List.of("after:broken", "before:gate", "after:gate", "after:subject"), trace); + sdk.close(); + } + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/FeatureAllocationTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/FeatureAllocationTest.java new file mode 100644 index 0000000..0c055b3 --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/FeatureAllocationTest.java @@ -0,0 +1,105 @@ +package com.featurevisor.sdk; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +public class FeatureAllocationTest { + private final ObjectMapper mapper = new ObjectMapper(); + + private Map flag(String key, int percentage) { + return new HashMap<>(Map.of( + "key", key, "bucketBy", "userId", + "traffic", List.of(new HashMap<>(Map.of( + "key", "1", "segments", "*", "percentage", percentage, "allocation", List.of()))))); + } + + private DatafileContent datafile(Map features) throws Exception { + return DatafileContent.fromJson(mapper.writeValueAsString(Map.of( + "schemaVersion", "2", "revision", "1", "segments", Map.of(), "features", features))); + } + + @Test + public void legacyRangesAreUnknownFieldsWithoutWarnings() throws Exception { + for (Object ranges : Arrays.asList(null, List.of(), List.of(List.of(50000, 100000)), + List.of(List.of(0, 100000)), "ignored")) { + for (int percentage : new int[] {0, 50000, 100000}) { + for (Boolean enabled : Arrays.asList(null, false, true)) { + Map feature = flag("test", percentage); + DatafileContent baselineData = datafile(Map.of("test", feature)); + baselineData.getFeatures().get("test").getTraffic().get(0).setEnabled(enabled); + Featurevisor baseline = Featurevisor.createFeaturevisor( + new Featurevisor.FeaturevisorOptions().datafile(baselineData).logLevel(FeaturevisorLogLevel.WARN)); + feature.put("ranges", ranges); + DatafileContent legacyData = datafile(Map.of("test", feature)); + legacyData.getFeatures().get("test").getTraffic().get(0).setEnabled(enabled); + List diagnostics = new ArrayList<>(); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(legacyData).logLevel(FeaturevisorLogLevel.WARN).onDiagnostic(diagnostics::add)); + for (int user = 0; user < 32; user++) { + Map context = Map.of("userId", "user-" + user); + assertEquals(mapper.valueToTree(baseline.evaluateFlag("test", context)), + mapper.valueToTree(sdk.evaluateFlag("test", context))); + } + assertTrue(diagnostics.isEmpty()); + assertFalse(mapper.valueToTree(legacyData.getFeatures().get("test")).has("ranges")); + sdk.close(); + baseline.close(); + } + } + } + } + + @Test + public void requiredVariationsAndPriorityExclusionUseNormalHashing() throws Exception { + Map> features = new HashMap<>(); + for (String key : List.of("cohort", "experimentA", "experimentB", "priority", "fallback")) { + features.put(key, flag(key, key.equals("priority") ? 50000 : 100000)); + } + features.get("cohort").put("variations", List.of( + Map.of("value", "a"), Map.of("value", "b"), Map.of("value", "holdout"))); + features.get("cohort").put("traffic", List.of(Map.of( + "key", "1", "segments", "*", "percentage", 100000, "allocation", List.of( + Map.of("variation", "a", "range", List.of(0, 40000)), + Map.of("variation", "b", "range", List.of(40000, 80000)), + Map.of("variation", "holdout", "range", List.of(80000, 100000)))))); + features.get("experimentA").put("requiredFeatures", List.of(Map.of("feature", "cohort", "variation", "a"))); + features.get("experimentB").put("requiredFeatures", List.of(Map.of("feature", "cohort", "variation", "b"))); + features.get("fallback").put("requiredFeatures", List.of(Map.of("feature", "priority", "enabled", false))); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(datafile(features)).logLevel(FeaturevisorLogLevel.WARN)); + Map counts = new HashMap<>(Map.of("a", 0, "b", 0, "holdout", 0, "priority", 0, "fallback", 0)); + int cohortOverlap = 0; + int priorityOverlap = 0; + for (int user = 0; user < 20000; user++) { + Map context = Map.of("userId", "user-" + user); + String variation = sdk.getVariation("cohort", context); + assertTrue(List.of("a", "b", "holdout").contains(variation)); + counts.merge(variation, 1, Integer::sum); + boolean a = sdk.isEnabled("experimentA", context); + boolean b = sdk.isEnabled("experimentB", context); + assertEquals("a".equals(variation), a); + assertEquals("b".equals(variation), b); + if (a && b) cohortOverlap++; + assertEquals("holdout".equals(variation), !a && !b); + boolean priority = sdk.isEnabled("priority", context); + boolean fallback = sdk.isEnabled("fallback", context); + assertNotEquals(priority, fallback); + if (priority && fallback) priorityOverlap++; + counts.merge(priority ? "priority" : "fallback", 1, Integer::sum); + } + assertEquals(0, cohortOverlap); + assertEquals(0, priorityOverlap); + assertEquals(20000, counts.get("a") + counts.get("b") + counts.get("holdout")); + assertEquals(20000, counts.get("priority") + counts.get("fallback")); + // Allow two percentage points around each intended population share. + Map.of("a", 8000, "b", 8000, "holdout", 4000, "priority", 10000, "fallback", 10000) + .forEach((key, expected) -> assertTrue(Math.abs(counts.get(key) - expected) <= 400, counts.toString())); + sdk.close(); + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/FeaturevisorTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/FeaturevisorTest.java index 4d29e41..3a75103 100644 --- a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/FeaturevisorTest.java +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/FeaturevisorTest.java @@ -637,7 +637,7 @@ public void testInterceptContextBeforeModule() { // Create module to intercept before FeaturevisorModule module = new FeaturevisorModule("unit-test"); - module.setBefore(options -> { + module.setBeforeEvaluation(options -> { intercepted[0] = true; interceptedFeatureKey[0] = options.getFeatureKey(); interceptedVariableKey[0] = options.getVariableKey(); @@ -720,7 +720,7 @@ public void testInterceptValueAfterModule() { // Create module to intercept after and manipulate value FeaturevisorModule module = new FeaturevisorModule("unit-test"); - module.setAfter((evaluation, options) -> { + module.setAfterEvaluation((evaluation, options) -> { intercepted[0] = true; interceptedFeatureKey[0] = options.getFeatureKey(); interceptedVariableKey[0] = options.getVariableKey(); @@ -867,13 +867,13 @@ public void testSetDatafileMergesByDefaultAndReplacesWhenRequested() throws Exce new Featurevisor.FeaturevisorOptions().datafile(firstDatafile) ); - sdk.setDatafile(secondDatafile); + assertTrue(sdk.setDatafile(secondDatafile)); assertNotNull(sdk.getFeature("first")); assertNotNull(sdk.getFeature("second")); assertEquals("second", sdk.getRevision()); - sdk.setDatafile(secondDatafile, true); + assertTrue(sdk.setDatafile(secondDatafile, true)); assertNull(sdk.getFeature("first")); assertNotNull(sdk.getFeature("second")); @@ -1047,7 +1047,7 @@ public void testHonourSimpleRequiredFeatures() { "myKey": { "key": "myKey", "bucketBy": "userId", - "required": ["requiredKey"], + "requiredFeatures": ["requiredKey"], "traffic": [ { "key": "1", @@ -1095,7 +1095,7 @@ public void testHonourSimpleRequiredFeatures() { "myKey": { "key": "myKey", "bucketBy": "userId", - "required": ["requiredKey"], + "requiredFeatures": ["requiredKey"], "traffic": [ { "key": "1", @@ -1161,9 +1161,9 @@ public void testHonourRequiredFeaturesWithVariation() { "myKey": { "key": "myKey", "bucketBy": "userId", - "required": [ + "requiredFeatures": [ { - "key": "requiredKey", + "feature": "requiredKey", "variation": "control" } ], @@ -1230,9 +1230,9 @@ public void testHonourRequiredFeaturesWithVariation() { "myKey": { "key": "myKey", "bucketBy": "userId", - "required": [ + "requiredFeatures": [ { - "key": "requiredKey", + "feature": "requiredKey", "variation": "treatment" } ], @@ -1689,7 +1689,7 @@ public void testGetVariationWithForceRules() { } @Test - public void testCheckIfEnabledForMutuallyExclusiveFeatures() { + public void testLegacyFeatureRangesAreIgnored() { final int[] bucketValue = {10000}; // Create module to control bucket value @@ -1709,7 +1709,7 @@ public void testCheckIfEnabledForMutuallyExclusiveFeatures() { "key": "mutex", "bucketBy": "userId", "ranges": [ - [0, 50000] + [50000, 100000] ], "traffic": [ { @@ -1744,6 +1744,9 @@ public void testCheckIfEnabledForMutuallyExclusiveFeatures() { bucketValue[0] = 40000; assertTrue(sdk.isEnabled("mutex", new HashMap<>())); + bucketValue[0] = 50000; + assertTrue(sdk.isEnabled("mutex", new HashMap<>())); + // Test with bucket value 60000 (should be disabled) bucketValue[0] = 60000; assertFalse(sdk.isEnabled("mutex", new HashMap<>())); diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/GlobalVariablesConformanceTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/GlobalVariablesConformanceTest.java index ccff656..b6deaf9 100644 --- a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/GlobalVariablesConformanceTest.java +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/GlobalVariablesConformanceTest.java @@ -33,7 +33,7 @@ private Object value(JsonNode node) { @Test void evaluatesGlobalVariablesAndRequiredFeaturesFromSharedFixture() throws Exception { JsonNode root = fixture(); - assertEquals(6, root.get("version").asInt()); + assertEquals(15, root.get("version").asInt()); JsonNode global = root.get("globalVariables"); Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() @@ -92,6 +92,95 @@ private Object value(JsonNode node) { assertEquals(variableCase.get("expectedOverrideKey").asText(), evaluation.getVariableOverrideKey()); } + @Test void evaluatesRequiredFeatureDetailCasesFromSharedFixture() throws Exception { + JsonNode contract = fixture().get("requiredFeatures"); + JsonNode cases = contract.get("evaluationCases"); + assertNotNull(cases); + assertTrue(cases.isArray() && cases.size() > 0); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(objectMapper.treeToValue(contract.get("datafile"), DatafileContent.class)) + .logLevel(FeaturevisorLogLevel.FATAL)); + try { + for (JsonNode testCase : cases) { + String name = testCase.get("name").asText(); + Map context = map(testCase.get("context")); + Evaluation evaluation; + switch (testCase.get("type").asText()) { + case "flag": + evaluation = f.evaluateFlag(testCase.get("feature").asText(), context); + break; + case "variation": + evaluation = f.evaluateVariation(testCase.get("feature").asText(), context); + break; + case "variable": + evaluation = testCase.has("feature") + ? f.evaluateVariable(testCase.get("feature").asText(), testCase.get("variable").asText(), context) + : f.evaluateVariable(testCase.get("variable").asText(), context); + break; + default: + throw new AssertionError("Unknown evaluation type in " + name); + } + JsonNode actual = objectMapper.valueToTree(evaluation); + JsonNode expected = testCase.get("expected"); + assertNotNull(expected, name); + assertTrue(expected.isObject() && expected.size() > 0, name); + expected.fields().forEachRemaining(field -> { + assertTrue(actual.has(field.getKey()), name + ": missing " + field.getKey()); + assertEquals(field.getValue(), actual.get(field.getKey()), name + ": " + field.getKey()); + }); + } + } finally { + f.close(); + } + } + + @Test void evaluatesGroupedRequiredFeaturesAndVariablesFromSharedFixture() throws Exception { + JsonNode root = fixture(); + JsonNode required = root.get("requiredVariables"); + for (JsonNode testCase : required.get("stickyCases")) { + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(objectMapper.treeToValue(required.get("datafile"), DatafileContent.class)) + .stickyVariables(map(testCase.get("stickyVariables"))) + .stickyFeatures(map(testCase.get("stickyFeatures"))) + .logLevel(FeaturevisorLogLevel.FATAL)); + String name = testCase.get("name").asText(); + assertEquals(testCase.get("matched").asBoolean(), f.isEnabled("gated"), name); + assertEquals(testCase.get("matched").asBoolean() ? "matched" : "default", + f.getVariable("root"), name); + assertEquals(testCase.get("matched").asBoolean() ? "matched" : "default", + f.getVariable("override"), name); + assertEquals(testCase.get("matched").asBoolean() ? "matched" : "default", + f.getVariable("featureOverride", "message"), name); + assertEquals(value(testCase.get("accumulated")), f.getVariable("accumulated"), name); + } + + for (JsonNode group : root.get("requiredFeatureGroups")) { + for (int index = 0; index < root.get("requiredFeatureGroupInputs").size(); index++) { + JsonNode input = root.get("requiredFeatureGroupInputs").get(index); + DatafileContent datafile = objectMapper.readValue(""" + {"schemaVersion":"2","revision":"groups","segments":{},"features":{ + "subject":{"bucketBy":"userId","traffic":[{"key":"all","segments":"*","percentage":100000}]} + }} + """, DatafileContent.class); + datafile.getFeatures().get("subject").setRequiredFeatures(objectMapper.convertValue( + group.get("requirements"), new TypeReference>() {})); + Map sticky = Map.of( + "a", Map.of("enabled", input.get("a").asBoolean()), + "b", Map.of("enabled", input.get("b").asBoolean())); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(datafile).stickyFeatures(sticky).logLevel(FeaturevisorLogLevel.FATAL)); + assertEquals(group.get("results").get(index).asBoolean(), f.isEnabled("subject"), + group.get("name").asText()); + } + } + + Featurevisor nulls = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(objectMapper.treeToValue(required.get("datafile"), DatafileContent.class)) + .logLevel(FeaturevisorLogLevel.FATAL)); + assertNull(nulls.getVariable("malformed")); + assertEquals("matched", nulls.getVariable("nullGate")); + } + @SuppressWarnings("unchecked") private void assertSameKeys(Object actual, JsonNode expected) { List actualValues = new ArrayList<>((List) actual); diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/IncludesAnyTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/IncludesAnyTest.java new file mode 100644 index 0000000..455684a --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/IncludesAnyTest.java @@ -0,0 +1,80 @@ +package com.featurevisor.sdk; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class IncludesAnyTest { + @Test + void primitiveArraysAndNestedConditions() { + Object[][] cases = { + {"mixed", Arrays.asList("red", 1, true, null), Arrays.asList("absent", 1.0), true, true}, + {"null", Arrays.asList((Object) null), Arrays.asList((Object) null), true, true}, + {"boolean", List.of(false), List.of(false), true, true}, + {"number not boolean", List.of(1), List.of(true), true, false}, + {"boolean not number", List.of(true), List.of(1), true, false}, + {"string not number", List.of("1"), List.of(1), true, false}, + {"string not boolean", List.of("true"), List.of(true), true, false}, + {"duplicates", List.of("red", "red"), List.of("blue", "red", "red"), true, true}, + {"empty actual", List.of(), Arrays.asList((Object) null), true, false}, + {"empty candidates", List.of(1), List.of(), true, false}, + {"both empty", List.of(), List.of(), true, false}, + {"structured actual", List.of(Map.of(), List.of(1), "red"), List.of("red"), true, true}, + {"structured ignored", List.of(Map.of(), List.of(1)), Arrays.asList(null, 1), true, false}, + {"object after match", List.of(1), List.of(1, Map.of()), false, false}, + {"array after match", List.of(1), List.of(1, List.of()), false, false}, + {"object first", List.of(1), List.of(Map.of(), 1), false, false}, + {"null actual", null, List.of(), false, false}, + {"object actual", Map.of(), List.of(), false, false}, + {"scalar actual", "red", List.of("red"), false, false}, + {"null candidates", List.of(), null, false, false}, + {"scalar candidates", List.of("red"), "red", false, false}, + {"object candidates", List.of(), Map.of(), false, false}, + }; + for (Object[] row : cases) { + for (Operator op : new Operator[]{Operator.INCLUDES_ANY, Operator.NOT_INCLUDES_ANY}) { + boolean want = (boolean) row[3] && ((boolean) row[4] == (op == Operator.INCLUDES_ANY)); + Map nested = new HashMap<>(); + nested.put("items", row[1]); + Condition leaf = new Condition(); + leaf.setAttribute("nested.items"); + leaf.setOperator(op); + leaf.setValue(row[2]); + assertEquals(want, Conditions.conditionIsMatched(leaf, Map.of("nested", nested), (pattern, flags) -> null), row[0] + op.toString()); + assertFalse(Conditions.conditionIsMatched(leaf, Map.of(), (pattern, flags) -> null)); + if (!(boolean) row[3]) { + Condition not = new Condition(); + not.setNot(List.of(leaf)); + assertTrue(Conditions.conditionIsMatched(not, Map.of("nested", nested), (pattern, flags) -> null)); + Condition or = new Condition(); + or.setOr(List.of(leaf)); + assertFalse(Conditions.conditionIsMatched(or, Map.of("nested", nested), (pattern, flags) -> null)); + } + } + } + } + + @Test + void featureAndGlobalRequirementsShareComparison() throws Exception { + Object[][] cases = { + {"includesAny", "[1.0]", true}, {"notIncludesAny", "[false]", true}, + {"includesAny", "[true]", false}, {"notIncludesAny", "[1]", false}, + {"includesAny", "[]", false}, {"notIncludesAny", "[]", true}, + {"includesAny", "[1,{}]", false}, {"notIncludesAny", "[1,[]]", false}, + }; + for (Object[] row : cases) { + String raw = "{\"schemaVersion\":\"2\",\"revision\":\"any\",\"segments\":{}," + + "\"features\":{\"gate\":{\"bucketBy\":\"userId\",\"conditions\":[{\"variable\":\"source\",\"path\":[\"items\"],\"operator\":\"OP\",\"value\":CANDIDATES}],\"traffic\":[{\"key\":\"all\",\"segments\":\"*\",\"percentage\":100000,\"enabled\":true}]}}," + + "\"variables\":{\"source\":{\"type\":\"object\",\"defaultValue\":{\"items\":[1,null,{\"x\":1}]}}," + + "\"gate\":{\"type\":\"boolean\",\"defaultValue\":true,\"disabledValue\":false,\"conditions\":[{\"variable\":\"source\",\"path\":[\"items\"],\"operator\":\"OP\",\"value\":CANDIDATES}]}}}"; + raw = raw.replace("OP", (String) row[0]).replace("CANDIDATES", (String) row[1]); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(new ObjectMapper().readValue(raw, DatafileContent.class)).logLevel(FeaturevisorLogLevel.FATAL)); + try { + assertEquals(row[2], sdk.isEnabled("gate", Map.of("userId", "user")), Arrays.toString(row)); + assertEquals(row[2], sdk.getVariableBoolean("gate"), Arrays.toString(row)); + } finally { sdk.close(); } + } + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/InitialLoadingTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/InitialLoadingTest.java new file mode 100644 index 0000000..1ec4fec --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/InitialLoadingTest.java @@ -0,0 +1,122 @@ +package com.featurevisor.sdk; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +final class InitialLoadingTest { + private static final String INITIAL = """ + {"schemaVersion":"4","revision":"initial", + "segments":{"audience":{"conditions":"{\\\"variable\\\":\\\"source\\\",\\\"operator\\\":\\\"notNull\\\"}"}, + "alias":{"conditions":"audience"},"broken":{"conditions":"{bad"}}, + "features":{"gate":{"hash":"stable","bucketBy":"userId","conditions":"alias", + "traffic":[{"key":"all","conditions":"*","percentage":100000}]}}, + "variables":{"source":{"hash":"one","type":"integer","defaultValue":1}, + "map":{"hash":"stable","type":"object","compose":[{"key":"enabled","feature":"gate"}]}, + "alias":{"hash":"stable","type":"object","compose":"map"}}} + """; + + private static Object field(Object owner, String name) throws Exception { + Field field = owner.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(owner); + } + + private static void assertCold(Featurevisor sdk) throws Exception { + Object provider = field(sdk, "evaluationData"); + for (String name : List.of("normalizedFeatures", "normalizedSegments", "normalizedVariables")) { + for (Object slot : ((Map) field(provider, name)).values()) assertNull(field(slot, "value")); + } + } + + private static void assertAffected(FeaturevisorEventDetails event, Set variables) { + assertEquals(Set.of("gate"), Set.copyOf((List) event.get("features"))); + assertEquals(variables, Set.copyOf((List) event.get("variables"))); + } + + @Test void initialEventDoesNotReadSelectorsButSegmentOnlyPreviousStateDoes() { + AtomicInteger reads = new AtomicInteger(); + Feature probe = new Feature() { + @Override public Object getConditions() { + reads.incrementAndGet(); + return "audience"; + } + }; + GlobalVariable value = new GlobalVariable(); + DatafileContent previous = new DatafileContent("4", "empty"); + previous.setFeatures(Map.of()); previous.setSegments(Map.of()); previous.setVariables(Map.of()); + DatafileContent incoming = new DatafileContent("4", "initial"); + incoming.setFeatures(Map.of("gate", probe)); incoming.setVariables(Map.of("source", value)); + incoming.setSegments(Map.of()); + for (boolean replace : List.of(false, true)) { + FeaturevisorEventDetails event = Events.getParamsForDatafileSetEvent(previous, incoming, replace); + assertAffected(event, Set.of("source")); + assertEquals("empty", event.get("previousRevision")); + assertEquals("initial", event.get("revision")); + assertEquals(true, event.get("revisionChanged")); + assertEquals(replace, event.get("replaced")); + assertEquals(0, reads.get(), "Initial notification must not build the dependency index"); + } + Segment segment = new Segment(); segment.setConditions("*"); + previous.setSegments(Map.of("audience", segment)); + Events.getParamsForDatafileSetEvent(previous, incoming, false); + assertTrue(reads.get() > 0, "A segment only previous datafile must use the update path"); + } + + @Test void firstPartialUpdateBuildsGraphsWithoutWarmingEntitiesOrDiagnostics() throws Exception { + List diagnostics = new ArrayList<>(); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafileString(INITIAL).logLevel(FeaturevisorLogLevel.ERROR).onDiagnostic(diagnostics::add)); + try { + assertCold(sdk); + assertTrue(diagnostics.isEmpty()); + List events = new ArrayList<>(); + sdk.on(FeaturevisorEventName.DATAFILE_SET, events::add); + assertTrue(sdk.setDatafile(""" + {"schemaVersion":"4","revision":"partial","segments":{},"features":{}, + "variables":{"source":{"hash":"two","type":"integer","defaultValue":2}}} + """)); + assertAffected(events.get(0), Set.of("source", "map", "alias")); + assertCold(sdk); + assertTrue(diagnostics.isEmpty()); + assertTrue(sdk.isEnabled("gate")); + assertEquals(Map.of("enabled", true), sdk.getVariable("alias")); + sdk.getSegment("broken"); sdk.getSegment("broken"); + assertEquals(1, diagnostics.size()); + assertEquals("conditions_parse_error", diagnostics.get(0).getCode()); + assertEquals("broken", diagnostics.get(0).getDetails().get("entityKey")); + } finally { sdk.close(); } + } + + @Test void firstUpdateUsesNewEdgesAndReplacementUsesRemovedEdges() throws Exception { + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafileString(INITIAL).logLevel(FeaturevisorLogLevel.FATAL)); + try { + List events = new ArrayList<>(); + sdk.on(FeaturevisorEventName.DATAFILE_SET, events::add); + assertTrue(sdk.setDatafile(""" + {"schemaVersion":"4","revision":"partial","segments":{}, + "features":{"gate":{"hash":"stable","conditions":{"variable":"added","operator":"notNull"}}}, + "variables":{"added":{"hash":"new","type":"integer","defaultValue":2}}} + """)); + assertAffected(events.get(0), Set.of("added", "map", "alias")); + } finally { sdk.close(); } + + sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafileString(INITIAL).logLevel(FeaturevisorLogLevel.FATAL)); + try { + List events = new ArrayList<>(); + sdk.on(FeaturevisorEventName.DATAFILE_SET, events::add); + DatafileContent replacement = DatafileContent.fromJson(INITIAL); + replacement.getVariables().remove("source"); + replacement.getFeatures().get("gate").setConditions("*"); + assertTrue(sdk.setDatafile(replacement, true)); + assertAffected(events.get(0), Set.of("source", "map", "alias")); + } finally { sdk.close(); } + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/InstanceEvaluationDataProviderTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/InstanceEvaluationDataProviderTest.java index 592831f..e977881 100644 --- a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/InstanceEvaluationDataProviderTest.java +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/InstanceEvaluationDataProviderTest.java @@ -30,6 +30,74 @@ public class InstanceEvaluationDataProviderTest { + @Test + public void testStringifiedExpressionsAreCachedWithoutCachingDecisions() { + DatafileContent datafile = new DatafileContent(); + datafile.setSchemaVersion("2"); + datafile.setRevision("conditions-cache"); + datafile.setSegments(new HashMap<>()); + datafile.setFeatures(new HashMap<>()); + InstanceEvaluationDataProvider evaluationData = new InstanceEvaluationDataProvider( + new InstanceEvaluationDataProvider.InstanceEvaluationDataProviderOptions() + .datafile(datafile) + .diagnostics(DiagnosticReporter.createDiagnosticReporter()) + ); + String source = "{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"}"; + + Object first = evaluationData.parseConditionsIfStringified(source); + Object second = evaluationData.parseConditionsIfStringified(source); + + assertSame(first, second); + assertTrue(evaluationData.allConditionsAreMatched(first, Map.of("country", "nl"))); + assertFalse(evaluationData.allConditionsAreMatched(second, Map.of("country", "de"))); + } + + @Test + public void testPublicSegmentParsingDoesNotShareCachedMutableConditions() { + String source = "{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"}"; + Segment segment = new Segment("country"); + segment.setConditions(source); + DatafileContent datafile = new DatafileContent(); + datafile.setSchemaVersion("2"); + datafile.setRevision("segment-ownership"); + datafile.setSegments(new HashMap<>(Map.of("country", segment))); + datafile.setFeatures(new HashMap<>()); + InstanceEvaluationDataProvider evaluationData = new InstanceEvaluationDataProvider( + new InstanceEvaluationDataProvider.InstanceEvaluationDataProviderOptions() + .datafile(datafile) + .diagnostics(DiagnosticReporter.createDiagnosticReporter()) + ); + + Object cached = evaluationData.parseConditionsIfStringified(source); + Object publicConditions = evaluationData.getSegment("country").getConditions(); + + assertNotSame(cached, publicConditions); + } + + @Test + public void testMalformedStringifiedConditionsStillReportEveryAttempt() { + List messages = new ArrayList<>(); + DiagnosticReporter diagnostics = DiagnosticReporter.createDiagnosticReporter( + new DiagnosticReporter.DiagnosticReporterOptions() + .handler((level, message, details) -> messages.add(message)) + ); + DatafileContent datafile = new DatafileContent(); + datafile.setSchemaVersion("2"); + datafile.setRevision("conditions-errors"); + datafile.setSegments(new HashMap<>()); + datafile.setFeatures(new HashMap<>()); + InstanceEvaluationDataProvider evaluationData = new InstanceEvaluationDataProvider( + new InstanceEvaluationDataProvider.InstanceEvaluationDataProviderOptions() + .datafile(datafile) + .diagnostics(diagnostics) + ); + + evaluationData.parseConditionsIfStringified("{"); + evaluationData.parseConditionsIfStringified("{"); + + assertEquals(2, messages.stream().filter("Error parsing conditions"::equals).count()); + } + @Test public void testConcurrentConditionEvaluationsShareRegexCacheSafely() throws Exception { DatafileContent datafile = new DatafileContent(); @@ -64,7 +132,7 @@ public void testSharedV3ConformanceFixture() throws Exception { assertNotNull(fixtureStream); ObjectMapper objectMapper = new ObjectMapper(); JsonNode fixture = objectMapper.readTree(fixtureStream); - assertEquals(6, fixture.get("version").asInt()); + assertEquals(15, fixture.get("version").asInt()); for (JsonNode testCase : fixture.get("numericBucketKeys")) { String bucketKey = Bucketer.getBucketKey( new Bucketer.GetBucketKeyOptions() @@ -115,6 +183,8 @@ public void testSharedV3ConformanceFixture() throws Exception { "pattern " + testCase.get("pattern").asText() + ", flags " + flags ); } + assertEquals(189, fixture.get("conditionCases").size()); + int executedAnyCases = 0; for (JsonNode testCase : fixture.get("conditionCases")) { Condition condition = objectMapper.treeToValue(testCase.get("condition"), Condition.class); @SuppressWarnings("unchecked") @@ -127,7 +197,9 @@ public void testSharedV3ConformanceFixture() throws Exception { evaluationData.allConditionsAreMatched(condition, context), testCase.get("name").asText() ); + if (testCase.get("name").asText().toLowerCase(java.util.Locale.ROOT).contains("includesany")) executedAnyCases++; } + assertEquals(73, executedAnyCases); JsonNode aggregateCase = fixture.get("defaults").get("aggregateCase"); DatafileContent aggregateDatafile = objectMapper.treeToValue( diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/LazySessionStorageTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/LazySessionStorageTest.java new file mode 100644 index 0000000..2abdc10 --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/LazySessionStorageTest.java @@ -0,0 +1,36 @@ +package com.featurevisor.sdk; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +final class LazySessionStorageTest { + @Test void rootBookkeepingDoesNotAllocateDependencyCollections() { + EvaluationSession session = new EvaluationSession(); + EvaluateOptions root = new EvaluateOptions("flag", "root"); + assertTrue(session.beginRoot(DependencyOf.from(root))); + session.updateCurrent(DependencyOf.from(new EvaluateOptions("flag", "redirect"))); + assertEquals("redirect", session.current().getFeatureKey()); + assertFalse(session.hasDependencyStorage()); + assertThrows(EvaluationSession.Cycle.class, () -> session.read( + new EvaluateOptions("flag", "root"), () -> true)); + session.endRoot(); + assertFalse(session.hasDependencyStorage()); + assertNull(session.current()); + assertTrue(session.beginRoot(DependencyOf.from(root))); + assertTrue(session.segment("segment", () -> true)); + assertTrue(session.hasDependencyStorage()); + session.endRoot(); + assertFalse(session.hasDependencyStorage()); + } + + @Test void capabilityEqualityKeepsJsonTypesButComparesNumbersByValue() throws Exception { + ObjectMapper json = new ObjectMapper(); + NativeV4CapabilitiesTest.jsonEquals(json.readTree("{\"a\":[1.0]}"), json.readTree("{\"a\":[1]}"), "numeric control"); + NativeV4CapabilitiesTest.jsonEquals(json.readTree("-0.0"), json.readTree("0"), "signed zero"); + for (String[] mismatch : new String[][]{{"1", "true"}, {"null", "false"}, {"\"1\"", "1"}, {"{}", "[]"}, {"{\"a\":null}", "{\"b\":null}"}}) { + assertThrows(AssertionError.class, () -> NativeV4CapabilitiesTest.jsonEquals( + json.readTree(mismatch[0]), json.readTree(mismatch[1]), "strict types")); + } + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/ModulePhaseSnapshotTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/ModulePhaseSnapshotTest.java new file mode 100644 index 0000000..62815f7 --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/ModulePhaseSnapshotTest.java @@ -0,0 +1,85 @@ +package com.featurevisor.sdk; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +final class ModulePhaseSnapshotTest { + private FeaturevisorModule module(String name, List trace, Consumer action) { + return new FeaturevisorModule(name) + .beforeEvaluation(options -> { trace.add(name + ":before"); action.accept("before"); return options; }) + .bucketKey(options -> { trace.add(name + ":key"); action.accept("key"); return options.getBucketKey(); }) + .bucketValue(options -> { trace.add(name + ":value"); action.accept("value"); return options.getBucketValue(); }) + .afterEvaluation((evaluation, options) -> { trace.add(name + ":after"); action.accept("after"); return evaluation; }); + } + + @Test void everyPhaseUsesStartingMembershipForFeatureAndGlobalEvaluations() { + for (boolean global : new boolean[]{false, true}) { + List phases = global ? List.of("before", "after") : List.of("before", "key", "value", "after"); + for (String target : phases) for (String mode : List.of("add", "removeAdd", "remove")) { + List trace = new ArrayList<>(); + AtomicReference owner = new AtomicReference<>(); + AtomicBoolean changed = new AtomicBoolean(); + AtomicInteger closed = new AtomicInteger(); + FeaturevisorModule late = module("late", trace, phase -> {}); + FeaturevisorModule actor = module("actor", trace, phase -> { + if (!target.equals(phase) || !changed.compareAndSet(false, true)) return; + if (!mode.equals("add")) owner.get().removeModule("other"); + if (!mode.equals("remove")) owner.get().addModule(late); + }); + FeaturevisorModule other = module("other", trace, phase -> {}).close(closed::incrementAndGet); + DatafileContent datafile = new ObjectMapper().convertValue(Map.of( + "schemaVersion", "4", "revision", "phase", "segments", Map.of(), + "features", Map.of("subject", Map.of("bucketBy", "userId", "traffic", List.of( + Map.of("key", "all", "conditions", "*", "percentage", 100000)))), + "variables", Map.of("subject", Map.of("type", "string", "defaultValue", "ok"))), DatafileContent.class); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(datafile).modules(List.of(actor, other)).logLevel(FeaturevisorLogLevel.FATAL)); + owner.set(sdk); + List expected = new ArrayList<>(); + for (String phase : phases) { + boolean later = phases.indexOf(phase) > phases.indexOf(target); + expected.add("actor:" + phase); + if (!later || mode.equals("add")) expected.add("other:" + phase); + if (later && !mode.equals("remove")) expected.add("late:" + phase); + } + for (int pass = 0; pass < 2; pass++) { + Evaluation result = global ? sdk.evaluateVariable("subject") : sdk.evaluateFlag("subject", Map.of("userId", "u")); + if (global) assertEquals("ok", result.getVariableValue()); else assertTrue(result.getEnabled()); + assertEquals(expected, trace, global + "/" + target + "/" + mode + "/" + pass); + assertEquals(mode.equals("add") ? 0 : 1, closed.get()); + trace.clear(); + expected.clear(); + for (String phase : phases) { + expected.add("actor:" + phase); + if (mode.equals("add")) expected.add("other:" + phase); + if (!mode.equals("remove")) expected.add("late:" + phase); + } + } + sdk.close(); + assertEquals(1, closed.get()); + } + } + } + + @Test void phaseIterationReusesCopyOnWriteStorageIncludingWhenEmpty() { + ModulesManager manager = new ModulesManager(new ModulesManager.ModulesManagerOptions()); + assertSame(manager.phaseModules(), manager.phaseModules()); + java.util.Iterator empty = manager.phaseModules().iterator(); + FeaturevisorModule first = new FeaturevisorModule("first"); + manager.add(first); + assertFalse(empty.hasNext()); + java.util.Iterator installed = manager.phaseModules().iterator(); + manager.remove("first"); + assertSame(first, installed.next()); + assertFalse(installed.hasNext()); + assertFalse(manager.phaseModules().iterator().hasNext()); + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/ModulesManagerTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/ModulesManagerTest.java index 7b5a0a3..7337fbe 100644 --- a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/ModulesManagerTest.java +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/ModulesManagerTest.java @@ -168,7 +168,7 @@ void testUnsubscribeReportsCloseErrorsOnce() { @Test void testBeforeModule() { FeaturevisorModule beforeModule = new FeaturevisorModule("before-test") - .before(options -> { + .beforeEvaluation(options -> { Map context = options.getContext(); if (context == null) { context = new HashMap<>(); @@ -219,7 +219,7 @@ void testBucketValueModule() { @Test void testAfterModule() { modulesManager.add(new FeaturevisorModule("after-test") - .after((evaluation, options) -> evaluation.copy().enabled(true))); + .afterEvaluation((evaluation, options) -> evaluation.copy().enabled(true))); Evaluation evaluation = new Evaluation("flag", "test-feature", "allocated") .enabled(false); @@ -233,7 +233,7 @@ void testAfterModule() { @Test void testMultipleModules() { modulesManager.add(new FeaturevisorModule("before1") - .before(options -> { + .beforeEvaluation(options -> { Map context = options.getContext(); if (context == null) { context = new HashMap<>(); @@ -243,7 +243,7 @@ void testMultipleModules() { })); modulesManager.add(new FeaturevisorModule("before2") - .before(options -> { + .beforeEvaluation(options -> { Map context = options.getContext(); context.put("module2", true); return options.copy().context(context); @@ -261,20 +261,17 @@ void testCanonicalModulePhaseOrder() { List order = new ArrayList<>(); for (String name : List.of("first", "second")) { modulesManager.add(new FeaturevisorModule(name) - .before(options -> { order.add("before:" + name); return options; }) .beforeEvaluation(options -> { order.add("beforeEvaluation:" + name); return options; }) .afterEvaluation((evaluation, options) -> { order.add("afterEvaluation:" + name); return evaluation; }) - .after((evaluation, options) -> { order.add("after:" + name); return evaluation; })); + ); } EvaluateOptions options = modulesManager.executeBeforeModules(new EvaluateOptions("flag", "test")); modulesManager.executeAfterModules(new Evaluation("flag", "test", "allocated"), options); assertEquals(List.of( - "before:first", "before:second", "beforeEvaluation:first", "beforeEvaluation:second", - "afterEvaluation:first", "afterEvaluation:second", - "after:first", "after:second" + "afterEvaluation:first", "afterEvaluation:second" ), order); } } diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/NativeParityRegressionTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/NativeParityRegressionTest.java new file mode 100644 index 0000000..3aebf1b --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/NativeParityRegressionTest.java @@ -0,0 +1,118 @@ +package com.featurevisor.sdk; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +final class NativeParityRegressionTest { + private static final ObjectMapper JSON = new ObjectMapper(); + + private DatafileContent datafile(Map features, Map variables) throws Exception { + return DatafileContent.fromJson(JSON.writeValueAsString(Map.of( + "schemaVersion", "4", "revision", "parity", "segments", Map.of(), + "features", features, "variables", variables))); + } + + @Test void internalFlagChecksKeepTheOuterRequesterAndMemoAcrossSelectorSites() throws Exception { + Object dependency = Map.of("feature", "gate"); + Map all = Map.of("key", "all", "conditions", "*", "percentage", 100000); + DatafileContent data = datafile(Map.of( + "gate", Map.of("bucketBy", "userId", "traffic", List.of(all)), + "subject", Map.of("bucketBy", "userId", "conditions", List.of(dependency, dependency), + "traffic", List.of(Map.of("key", "all", "conditions", dependency, "percentage", 100000, + "allocation", List.of(Map.of("variation", "on", "range", List.of(0, 100000))))), + "variations", List.of(Map.of("value", "on")), + "variablesSchema", Map.of("value", Map.of("type", "string", "defaultValue", "ok")))), Map.of()); + for (boolean transform : List.of(false, true)) { + List parents = new ArrayList<>(); + FeaturevisorModule module = new FeaturevisorModule("requester").beforeEvaluation(options -> { + if (options.getDependencyOf() != null) { + parents.add(options.getDependencyOf()); + assertEquals("condition", options.getDependencyPurpose()); + } else if (transform) { + return options.copy().featureKey("subject"); + } + return options; + }); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(data).modules(List.of(module)).logLevel(FeaturevisorLogLevel.FATAL)); + try { + String key = transform ? "alias" : "subject"; + for (int attempt = 0; attempt < 2; attempt++) { + parents.clear(); + assertEquals("on", f.getVariation(key)); + assertEquals(List.of(new DependencyOf(Evaluation.TYPE_VARIATION, "subject", null)), parents); + parents.clear(); + assertEquals("ok", f.getVariable(key, "value")); + assertEquals(List.of(new DependencyOf(Evaluation.TYPE_VARIABLE, "subject", "value")), parents); + } + } finally { f.close(); } + } + } + + @Test void repeatedInvalidJsonLeavesMemoizeTheOrdinaryGetterNull() throws Exception { + for (boolean featureSource : List.of(false, true)) { + Object leaf = featureSource + ? Map.of("feature", "sourceFeature", "variable", "source", "operator", "isNull") + : Map.of("variable", "source", "operator", "isNull"); + List diagnostics = new ArrayList<>(); + AtomicInteger reads = new AtomicInteger(); + FeaturevisorModule module = new FeaturevisorModule("reads").beforeEvaluation(options -> { + if ("source".equals(options.getVariableKey())) { + reads.incrementAndGet(); + assertEquals(new DependencyOf(Evaluation.TYPE_VARIABLE, null, "subject"), options.getDependencyOf()); + } + return options; + }); + DatafileContent data = datafile(featureSource ? Map.of("sourceFeature", Map.of( + "bucketBy", "userId", "traffic", List.of(Map.of("key", "all", "conditions", "*", "percentage", 100000)), + "variablesSchema", Map.of("source", Map.of("type", "json", "defaultValue", "{oops")))) : Map.of(), + Map.of("source", Map.of("type", "json", "defaultValue", "{oops"), + "subject", Map.of("type", "string", "defaultValue", "ok", "conditions", List.of(leaf, leaf)))); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(data).modules(List.of(module)).logLevel(FeaturevisorLogLevel.ERROR).onDiagnostic(diagnostics::add)); + try { + for (int attempt = 1; attempt <= 2; attempt++) { + assertEquals("ok", f.getVariable("subject")); + assertEquals(attempt, reads.get()); + assertEquals(attempt, diagnostics.stream().filter(d -> d.getMessage().equals("getVariable failed")).count()); + } + } finally { f.close(); } + } + } + + @Test void jsonStringAliasesDecodeOnceAndKeepConditionAndCompositionMemosSeparate() throws Exception { + for (String value : List.of("123", "null", "{\"nested\":true}", "\"quoted\"")) { + Object leaf = Map.of("variable", "source", "operator", "equals", "value", value); + AtomicInteger reads = new AtomicInteger(); + List purposes = new ArrayList<>(); + FeaturevisorModule module = new FeaturevisorModule("purposes").beforeEvaluation(options -> { + if ("source".equals(options.getVariableKey())) { + reads.incrementAndGet(); purposes.add(options.getDependencyPurpose()); + } + return options; + }); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(datafile(Map.of(), Map.of( + "source", Map.of("type", "json", "defaultValue", JSON.writeValueAsString(value)), + "alias", Map.of("type", "json", "compose", "source"), + "aliasAgain", Map.of("type", "json", "compose", "alias"), + "subject", Map.of("type", "object", "conditions", List.of(leaf, leaf), "compose", List.of( + Map.of("key", "first", "variable", "source"), Map.of("key", "second", "variable", "source")))))) + .modules(List.of(module)).logLevel(FeaturevisorLogLevel.FATAL)); + try { + for (int attempt = 0; attempt < 2; attempt++) { + reads.set(0); purposes.clear(); + assertEquals(Map.of("first", value, "second", value), f.getVariable("subject")); + assertEquals(2, reads.get()); + assertEquals(List.of("condition", "composition"), purposes); + assertEquals(value, f.getVariable("aliasAgain")); + } + } finally { f.close(); } + } + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/NativeV4CapabilitiesTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/NativeV4CapabilitiesTest.java new file mode 100644 index 0000000..b4f0530 --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/NativeV4CapabilitiesTest.java @@ -0,0 +1,167 @@ +package com.featurevisor.sdk; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +final class NativeV4CapabilitiesTest { + private final ObjectMapper json = new ObjectMapper(); + private JsonNode fixture(String name) throws Exception { + return json.readTree(getClass().getResourceAsStream("/conformance/" + name + ".json")); + } + private Map map(JsonNode value) { + assertTrue(value == null || value.isMissingNode() || value.isObject(), "expected an object"); + return value == null || value.isMissingNode() ? Map.of() : json.convertValue(value, Map.class); + } + static void jsonEquals(JsonNode expected, JsonNode actual, String label) { + assertNotNull(actual, label + ": missing value"); + if (expected.isNumber() && actual.isNumber()) { + assertTrue(expected.doubleValue() == actual.doubleValue(), label + ": numeric value"); + } else if (expected.isObject() && actual.isObject()) { + assertEquals(expected.size(), actual.size(), label); + expected.fields().forEachRemaining(field -> + jsonEquals(field.getValue(), actual.get(field.getKey()), label + "." + field.getKey())); + } else if (expected.isArray() && actual.isArray()) { + assertEquals(expected.size(), actual.size(), label); + for (int i = 0; i < expected.size(); i++) jsonEquals(expected.get(i), actual.get(i), label + "[" + i + "]"); + } else { + assertEquals(expected, actual, label); + } + } + private void fields(JsonNode expected, Evaluation evaluation, String label) { + assertTrue(expected != null && expected.isObject() && !expected.isEmpty(), label + " expected fields"); + JsonNode actual = json.valueToTree(evaluation); + expected.fields().forEachRemaining(field -> { + if (!field.getKey().equals("value")) { + assertTrue(actual.has(field.getKey()), label + ": missing " + field.getKey()); + jsonEquals(field.getValue(), actual.get(field.getKey()), label + ": " + field.getKey()); + } + }); + } + + @Test void diagnosticsFixtureChecksRevisionCacheAndReentry() throws Exception { + JsonNode fixture = fixture("sdk-diagnostics"); + assertEquals("featurevisor.sdk-diagnostics", fixture.path("kind").textValue()); + assertEquals(1, fixture.path("version").intValue()); + assertTrue(fixture.path("cases").isArray() && !fixture.path("cases").isEmpty()); + List repeatedCases = new ArrayList<>(); + fixture.get("cases").forEach(repeatedCases::add); + fixture.get("cases").forEach(repeatedCases::add); + for (JsonNode row : repeatedCases) { + List seen = new ArrayList<>(); + Featurevisor[] owner = new Featurevisor[1]; + boolean[] reentering = {false}; + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .logLevel(FeaturevisorLogLevel.ERROR).onDiagnostic(d -> { + if (!d.getCode().equals("conditions_parse_error")) return; + seen.add(json.valueToTree(Map.of("code", d.getCode(), "level", d.getLevel().name().toLowerCase(java.util.Locale.ROOT), + "details", d.getDetails(), "revision", owner[0].getRevision()))); + if ("feature".equals(d.getDetails().get("entityType")) && !reentering[0]) { + reentering[0] = true; + try { + int before = seen.size(); + owner[0].isEnabled((String) d.getDetails().get("entityKey")); + assertEquals(before, seen.size(), "reentry diagnostics"); + } finally { reentering[0] = false; } + } + })); + owner[0] = f; + try { + assertTrue(row.path("operations").isArray() && !row.path("operations").isEmpty()); + for (JsonNode op : row.get("operations")) { + int before = seen.size(); + boolean result; + switch (op.path("action").textValue()) { + case "setDatafile": result = f.setDatafile(op.get("datafile").toString(), op.get("replace").booleanValue()); break; + case "isEnabled": result = f.isEnabled(op.get("feature").textValue()); break; + default: throw new AssertionError("unsupported diagnostics action"); + } + assertTrue(op.get("expectedResult").isBoolean()); + assertEquals(op.get("expectedResult").booleanValue(), result, row.path("name").asText()); + List unmatched = new ArrayList<>(seen.subList(before, seen.size())); + for (JsonNode expected : op.get("diagnostics")) assertTrue(unmatched.remove(expected), "missing diagnostic " + expected); + assertTrue(unmatched.isEmpty(), "unexpected diagnostics " + unmatched); + } + } finally { f.close(); } + } + } + + @Test void allConditionsCasesTwiceWithPreorderDependenciesAndOrdinaryGetters() throws Exception { + JsonNode fixture = fixture("sdk-conditions"); + assertEquals(1, fixture.path("version").asInt()); + assertEquals("unified-conditions", fixture.path("capability").asText()); + assertTrue(fixture.path("cases").isArray() && !fixture.path("cases").isEmpty()); + int[] executed = new int[2]; + for (JsonNode row : fixture.path("cases")) { + List> dependencies = new ArrayList<>(); + FeaturevisorModule module = new FeaturevisorModule("dependencies").beforeEvaluation(options -> { + if (options.getDependencyPurpose() != null) { + Map entry = new LinkedHashMap<>(); + if (options.getFeatureKey() != null) entry.put("feature", options.getFeatureKey()); + if (options.getVariableKey() != null) entry.put("variable", options.getVariableKey()); + entry.put("dependencyPurpose", options.getDependencyPurpose()); + dependencies.add(entry); + } + return options; + }); + DatafileContent datafile = DatafileContent.fromJson(row.get("datafile").toString()); + String before = datafile.toJson(); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(datafile).logLevel(FeaturevisorLogLevel.FATAL).modules(List.of(module)) + .stickyFeatures(map(row.path("options").get("stickyFeatures"))) + .stickyVariables(map(row.path("options").get("stickyVariables")))); + try { + String label = row.path("name").asText(); + Map context = map(row.get("context")); + for (int attempt = 0; attempt < 2; attempt++) { + dependencies.clear(); + boolean flag = row.path("type").asText().equals("flag"); + assertTrue(flag || row.path("type").asText().equals("variable"), "unsupported case type"); + Evaluation evaluation = flag ? f.evaluateFlag(row.path("feature").asText(), context) + : f.evaluateVariable(row.path("variable").asText(), context); + fields(row.get("expected"), evaluation, label); + if (row.has("dependencies")) jsonEquals(row.get("dependencies"), json.valueToTree(dependencies), label); + if (flag) assertEquals(row.path("expected").path("enabled").asBoolean(), f.isEnabled(row.path("feature").asText(), context), label); + else if (row.path("expected").has("value")) jsonEquals(row.path("expected").get("value"), json.valueToTree(f.getVariable(row.path("variable").asText(), context)), label); + assertEquals(before, datafile.toJson(), label + " input ownership"); + executed[attempt]++; + } + } finally { f.close(); } + } + assertArrayEquals(new int[]{fixture.path("cases").size(), fixture.path("cases").size()}, executed); + } + + @Test void allCompositionCasesTwiceThroughBothGetterForms() throws Exception { + JsonNode fixture = fixture("sdk-composition"); + assertEquals(2, fixture.path("version").asInt()); + assertEquals("composition-and-overrides", fixture.path("capability").asText()); + assertTrue(fixture.path("cases").isArray() && !fixture.path("cases").isEmpty()); + int[] executed = new int[2]; + for (JsonNode row : fixture.path("cases")) { + DatafileContent datafile = DatafileContent.fromJson(fixture.get("datafile").toString()); + String before = datafile.toJson(); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(datafile).logLevel(FeaturevisorLogLevel.FATAL) + .stickyFeatures(map(row.get("stickyFeatures"))).stickyVariables(map(row.get("stickyVariables")))); + try { + String key = row.path("variable").asText(), label = row.path("name").asText(); + Map context = map(row.get("context")); + for (int attempt = 0; attempt < 2; attempt++) { + Object a = f.getVariable(key, context); + jsonEquals(row.get("expectedValue"), json.valueToTree(a), label); + fields(row.get("expectedEvaluation"), f.evaluateVariable(key, context), label); + if (datafile.getVariables().get(key).getCompose() instanceof List && a instanceof Map + && !row.path("expectedEvaluation").path("reason").asText().equals("sticky")) assertNotSame(a, f.getVariable(key, context), label); + assertEquals(before, datafile.toJson(), label + " input ownership"); + executed[attempt]++; + } + } finally { f.close(); } + } + assertArrayEquals(new int[]{fixture.path("cases").size(), fixture.path("cases").size()}, executed); + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/NativeV4HardeningTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/NativeV4HardeningTest.java new file mode 100644 index 0000000..3eb26c8 --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/NativeV4HardeningTest.java @@ -0,0 +1,254 @@ +package com.featurevisor.sdk; + +import java.util.ArrayList; +import java.util.AbstractMap; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +final class NativeV4HardeningTest { + private DatafileContent datafile(String version, String revision, Map segments, + Map features, Map variables) { + DatafileContent datafile = new DatafileContent(version, revision); + datafile.setSegments(segments); datafile.setFeatures(features); datafile.setVariables(variables); + return datafile; + } + private GlobalVariable variable(Object value) { + GlobalVariable result = new GlobalVariable(); + result.setType(VariableType.OBJECT); result.setDefaultValue(value); + return result; + } + private GlobalVariable alias(String key) { + GlobalVariable result = new GlobalVariable(); result.setCompose(key); return result; + } + private Feature flag(Object conditions) { + Feature result = new Feature(); result.setBucketBy(new Bucket("userId")); result.setConditions(conditions); + Traffic rule = new Traffic("all"); rule.setConditions("*"); rule.setPercentage(100000); + result.setTraffic(List.of(rule)); return result; + } + private Segment segment(Object conditions) { Segment result = new Segment(); result.setConditions(conditions); return result; } + private Featurevisor instance(DatafileContent datafile, FeaturevisorModule... modules) { + return Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().datafile(datafile) + .logLevel(FeaturevisorLogLevel.FATAL).modules(List.of(modules))); + } + + @Test void eagerAndLazyDiagnosticsStoreAndCacheBeforeReentrantCallbacks() { + for (String version : List.of("2", "4")) { + List diagnostics = new ArrayList<>(); + Featurevisor[] f = {null}; + f[0] = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .logLevel(FeaturevisorLogLevel.ERROR).onDiagnostic(diagnostic -> { + if (!diagnostic.getCode().equals("conditions_parse_error")) return; + diagnostics.add(diagnostic); + assertEquals("accepted", f[0].getRevision()); + Evaluation result = f[0].evaluateFlag("bad"); + assertEquals("conditions_unmet", result.getReason()); + assertEquals(Map.of("or", List.of()), result.getConditions()); + })); + Feature bad = flag("{"); + DatafileContent incoming = datafile(version, "accepted", Map.of(), Map.of("bad", bad), Map.of()); + assertTrue(f[0].setDatafile(incoming)); + assertEquals(version.equals("2") ? 1 : 0, diagnostics.size()); + assertFalse(f[0].isEnabled("bad")); + assertEquals(1, diagnostics.size()); + assertEquals(Set.of("conditions", "entityType", "entityKey"), diagnostics.get(0).getDetails().keySet()); + assertEquals("feature", diagnostics.get(0).getDetails().get("entityType")); + assertEquals("{", bad.getConditions()); + assertTrue(f[0].setDatafile(datafile("4", "accepted", Map.of(), Map.of("other", flag("*")), Map.of()))); + assertFalse(f[0].isEnabled("bad")); assertEquals(1, diagnostics.size()); + assertTrue(f[0].setDatafile(incoming)); + assertFalse(f[0].isEnabled("bad")); assertEquals(2, diagnostics.size()); + assertTrue(f[0].setDatafile(incoming, true)); + assertFalse(f[0].isEnabled("bad")); assertEquals(3, diagnostics.size()); + f[0].close(); + } + } + + @Test void stickyJsonAndGatedCompositionOwnershipUseOrdinaryGetterSemantics() { + GlobalVariable json = variable("\"null\""); json.setType(VariableType.JSON); + GlobalVariable subject = new GlobalVariable(); subject.setCompose(List.of(Map.of("key", "value", "variable", "json"))); + subject.setConditions(List.of()); Map disabled = new HashMap<>(Map.of("value", "off")); subject.setDisabledValue(disabled); + GlobalVariable alias = alias("json"); alias.setType(VariableType.JSON); + Featurevisor f = instance(datafile("4", "sticky", Map.of(), Map.of(), Map.of("json", json, "alias", alias, "subject", subject))); + assertEquals("null", f.getVariable("alias")); + f.setStickyVariables(Map.of("json", "\"sticky\"")); + assertEquals("sticky", f.getVariable("json")); assertEquals("sticky", f.getVariable("alias")); + Object first = f.getVariable("subject"), second = f.getVariable("subject"); + assertEquals(disabled, first); assertNotSame(disabled, first); assertNotSame(first, second); + f.close(); + } + + @Test void mixedSelectorsExposeFlattenedConditionsAndRetainEmptyOperands() { + Object attribute = Map.of("attribute", "country", "operator", "equals", "value", "nl"); + Feature subject = flag(List.of(attribute)); subject.setRequiredFeatures(List.of("missing")); + Force force = new Force(); force.setConditions(List.of(attribute)); force.setSegments(List.of("audience")); + force.setRequiredFeatures(List.of("missing")); subject.setForce(List.of(force)); + Normalize.Result normalized = Normalize.feature("subject", subject, false); + assertEquals(List.of(attribute, Map.of("feature", "missing")), normalized.value.getConditions()); + assertEquals(List.of(attribute, "audience", Map.of("feature", "missing")), normalized.value.getForce().get(0).getConditions()); + subject.setConditions(List.of()); + Featurevisor f = instance(datafile("4", "empty", Map.of(), Map.of("subject", subject), Map.of())); + assertEquals(List.of(Map.of("and", List.of()), Map.of("feature", "missing")), f.evaluateFlag("subject").getConditions()); + assertEquals(List.of(), subject.getConditions()); f.close(); + } + + @Test void concurrentReplacementsAndReentrantCallbacksPublishCompleteSnapshotsWithoutLocksHeld() throws Exception { + java.util.concurrent.ExecutorService pool = java.util.concurrent.Executors.newFixedThreadPool(4); + Featurevisor f = instance(datafile("4", "start", Map.of(), Map.of("good", flag("*")), Map.of("source", variable("a"), "alias", alias("source")))); + try { + FeaturevisorUnsubscribe unsubscribe = f.on(FeaturevisorEventName.DATAFILE_SET, event -> { + try { pool.submit(() -> f.setContext(Map.of("callback", true))).get(5, java.util.concurrent.TimeUnit.SECONDS); } + catch (Exception error) { throw new AssertionError(error); } + }); + assertTrue(f.setDatafile(datafile("4", "callback", Map.of(), Map.of(), Map.of()))); + unsubscribe.unsubscribe(); + List> tasks = new ArrayList<>(); + tasks.add(pool.submit(() -> { + for (int index = 0; index < 100; index++) { + assertTrue(f.setDatafile(datafile("4", "revision" + index, Map.of(), Map.of("good", flag("*")), + Map.of("source", variable(index % 2 == 0 ? "a" : "b"), "alias", alias("source"))), true)); + } + })); + for (int reader = 0; reader < 3; reader++) tasks.add(pool.submit(() -> { + for (int index = 0; index < 300; index++) { + assertTrue(f.isEnabled("good")); + assertTrue(List.of("a", "b").contains(f.getVariable("alias"))); + } + })); + for (java.util.concurrent.Future task : tasks) task.get(20, java.util.concurrent.TimeUnit.SECONDS); + } finally { pool.shutdownNow(); f.close(); } + } + + @Test void retainedUnreadLegacyAndV4DefinitionsKeepTheirEmptySemanticsAcrossMixedMerges() { + Feature old = flag(null); old.getTraffic().get(0).setConditions(null); old.getTraffic().get(0).setSegments(List.of()); + Feature modern = flag(List.of()); + Featurevisor f = instance(datafile("2", "old", Map.of("old", segment(List.of())), Map.of("old", old), Map.of())); + assertTrue(f.setDatafile(datafile("4", "new", Map.of("new", segment(List.of())), Map.of("new", modern), Map.of()))); + assertTrue(f.isEnabled("old")); assertFalse(f.isEnabled("new")); + assertTrue(f.setDatafile(datafile("2", "later", Map.of(), Map.of("legacyRead", flag("old"), "modernRead", flag("new")), Map.of()))); + assertTrue(f.isEnabled("legacyRead")); assertFalse(f.isEnabled("modernRead")); + assertEquals(List.of(), old.getTraffic().get(0).getSegments()); + assertTrue(f.setDatafile(datafile("4", "replacement", Map.of(), Map.of("new", flag("*")), Map.of()), true)); + assertFalse(f.isEnabled("old")); assertTrue(f.isEnabled("new")); f.close(); + } + + @Test void malformedSelectorsCannotBeInvertedThroughSegmentsAndEachSelectorReportsOnce() { + List errors = new ArrayList<>(); + Feature bad = flag(Map.of("not", List.of("badAlias"))); + Force force = new Force(); force.setConditions("{"); force.setSegments("{"); force.setEnabled(true); + bad.setForce(List.of(force)); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(datafile("4", "errors", Map.of("badAlias", segment("bad"), "bad", segment("{")), + Map.of("bad", bad, "good", flag("*")), Map.of())) + .logLevel(FeaturevisorLogLevel.ERROR).onDiagnostic(errors::add)); + assertTrue(errors.isEmpty()); + assertEquals("conditions_unmet", f.evaluateFlag("bad").getReason()); + assertFalse(f.isEnabled("bad")); assertTrue(f.isEnabled("good")); + assertEquals(3, errors.stream().filter(d -> d.getCode().equals("conditions_parse_error")).count()); + f.close(); + } + + @Test void borrowedCompositionsDoNotTraverseSourceValuesForMemoization() { + Map borrowed = new AbstractMap<>() { + @Override public Set> entrySet() { throw new AssertionError("borrowed value traversed"); } + }; + AtomicInteger reads = new AtomicInteger(); + GlobalVariable subject = new GlobalVariable(); + subject.setCompose(List.of(Map.of("key", "first", "variable", "source"), Map.of("key", "second", "variable", "source"))); + FeaturevisorModule module = new FeaturevisorModule("borrowed").afterEvaluation((evaluation, options) -> { + if ("source".equals(options.getVariableKey())) { reads.incrementAndGet(); return evaluation.variableValue(borrowed); } + return evaluation; + }); + Featurevisor f = instance(datafile("4", "borrowed", Map.of(), Map.of(), Map.of("subject", subject, "source", variable(null))), module); + Map result = (Map) f.getVariable("subject"); + assertSame(borrowed, result.get("first")); assertSame(borrowed, result.get("second")); assertEquals(1, reads.get()); + f.getVariable("subject"); assertEquals(2, reads.get()); f.close(); + } + + @Test void conditionSnapshotsAreOwnedAndPurposeMemoizationDoesNotMixWithComposition() { + Map source = new HashMap<>(Map.of("enabled", true)); + List purposes = new ArrayList<>(); + List retained = new ArrayList<>(); + GlobalVariable subject = new GlobalVariable(); + Object condition = Map.of("variable", "source", "path", List.of("enabled"), "operator", "equals", "value", true); + subject.setConditions(condition); + subject.setCompose(List.of(Map.of("key", "first", "variable", "source"), Map.of("key", "second", "variable", "source"))); + VariableOverride override = new VariableOverride(); override.setConditions(condition); override.setKey("snapshot"); + VariableMutationOperation operation = new VariableMutationOperation(); operation.setOp("set"); operation.setPath(List.of()); operation.setValue(Map.of("snapshot", true)); + override.setOperations(List.of(operation)); override.setOnMatch(1); override.setOnMiss(1); + subject.setOverrideProgram(List.of(override)); + FeaturevisorModule module = new FeaturevisorModule("purposes").beforeEvaluation(options -> { + retained.add(options); + if ("source".equals(options.getVariableKey())) { + purposes.add(options.getDependencyPurpose()); + assertEquals("subject", options.getDependencyOf().getVariableKey()); + if ("composition".equals(options.getDependencyPurpose())) source.put("enabled", false); + } else assertNull(options.getDependencyPurpose()); + return options; + }); + Featurevisor f = instance(datafile("4", "purposes", Map.of(), Map.of(), Map.of("subject", subject, "source", variable(source))), module); + Evaluation result = f.evaluateVariable("subject"); + assertEquals("variable_override_rule", result.getReason()); assertEquals(Map.of("snapshot", true), result.getVariableValue()); + assertEquals(List.of("condition", "composition"), purposes); + retained.forEach(options -> assertNull(options.getStickySource())); + assertFalse((Boolean) source.get("enabled")); f.close(); + } + + @Test void mixedCyclesAndDepthErrorsLeaveSubsequentCallsClean() { + for (boolean cycle : List.of(false, true)) { + Map variables = new LinkedHashMap<>(); + for (int index = 0; index < 300; index++) variables.put("v" + index, alias("v" + (index + 1))); + variables.put("v300", cycle ? alias("v0") : variable("end")); + GlobalVariable mixed = alias("v0"); mixed.setConditions("segment"); variables.put("mixed", mixed); + Featurevisor f = instance(datafile("4", "depth", Map.of("segment", segment(Map.of("feature", "gate"))), + Map.of("gate", flag(Map.of("variable", "mixed", "operator", "notNull")), "good", flag("*")), variables)); + assertEquals("error", f.evaluateVariable("v0").getReason()); + assertEquals("error", f.evaluateVariable("mixed").getReason()); + assertTrue(f.isEnabled("good")); assertEquals("variable_not_found", f.evaluateVariable("missing").getReason()); f.close(); + } + } + + @Test void compositionPreservesChildStickyStateAndEffectiveModuleContext() { + GlobalVariable subject = new GlobalVariable(); subject.setCompose(List.of( + Map.of("key", "feature", "feature", "flag"), Map.of("key", "value", "variable", "source"))); + GlobalVariable source = variable("default"); + VariableOverride override = new VariableOverride("regional"); override.setConditions(Map.of("attribute", "country", "operator", "equals", "value", "nl")); source.setOverrides(List.of(override)); + FeaturevisorModule module = new FeaturevisorModule("context").beforeEvaluation(options -> { + if (options.getDependencyPurpose() == null) return options.copy().context(Map.of("country", "nl")); + assertEquals(Map.of("country", "nl"), options.getContext()); return options; + }); + Featurevisor f = instance(datafile("4", "child", Map.of(), Map.of("flag", flag("*")), Map.of("subject", subject, "source", source)), module); + f.setContext(Map.of("country", "us", "unrelated", true)); + ChildInstance child = f.spawn(Map.of("country", "de"), new Featurevisor.SpawnOptions() + .stickyFeatures(Map.of("flag", Map.of("enabled", false))).stickyVariables(Map.of("source", "child"))); + assertEquals(Map.of("feature", false, "value", "child"), child.getVariable("subject")); + assertEquals(Map.of("feature", true, "value", "regional"), f.getVariable("subject")); + child.setStickyVariables(Map.of("subject", Map.of("whole", true))); + assertEquals(Map.of("whole", true), child.getVariable("subject")); child.close(); f.close(); + } + + @Test void updateIndexTraversesMixedRawConditionsAndCompositionWithoutDiagnostics() { + GlobalVariable source = variable(true); source.setHash("before"); + GlobalVariable composed = alias("source"); composed.setHash("alias"); + GlobalVariable consumer = variable("yes"); consumer.setHash("consumer"); consumer.setConditions("middle"); + Feature subject = flag("middle"); subject.setHash("feature"); + Featurevisor f = instance(datafile("4", "before", Map.of("middle", segment("{\"variable\":\"alias\",\"operator\":\"equals\",\"value\":true}"), "bad", segment("{")), + Map.of("subject", subject), Map.of("source", source, "alias", composed, "consumer", consumer))); + List events = new ArrayList<>(); List errors = new ArrayList<>(); + f.on(FeaturevisorEventName.DATAFILE_SET, events::add); + FeaturevisorModule observer = new FeaturevisorModule("diagnostics").setup(api -> api.onDiagnostic(errors::add)); f.addModule(observer); + GlobalVariable next = variable(false); next.setHash("after"); + assertTrue(f.setDatafile(datafile("4", "after", Map.of(), Map.of(), Map.of("source", next)))); + assertTrue(((List) events.get(0).get("features")).contains("subject")); + assertTrue(((List) events.get(0).get("variables")).containsAll(List.of("source", "alias", "consumer"))); + assertTrue(errors.stream().noneMatch(d -> d.getCode().equals("conditions_parse_error"))); + assertFalse(f.isEnabled("subject")); + assertTrue(f.setDatafile(datafile("4", "removed", Map.of("middle", segment("*")), Map.of("subject", subject), Map.of("consumer", consumer)), true)); + assertTrue(((List) events.get(1).get("variables")).containsAll(List.of("source", "alias", "consumer"))); f.close(); + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/NumberVariableTypeTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/NumberVariableTypeTest.java new file mode 100644 index 0000000..d643b42 --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/NumberVariableTypeTest.java @@ -0,0 +1,36 @@ +package com.featurevisor.sdk; + +import org.junit.jupiter.api.Test; + + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class NumberVariableTypeTest { + + @Test + public void readsNumberAndEarlierDoubleVariablesTheSameWay() throws Exception { + String datafileJson = """ + { + "schemaVersion": "4", + "revision": "1", + "segments": {}, + "features": {}, + "variables": { + "rate": { "type": "number", "defaultValue": 2.5 }, + "ratio": { "type": "double", "defaultValue": 1.5 } + } + } + """; + + DatafileContent datafile = DatafileContent.fromJson(datafileJson); + assertEquals(VariableType.NUMBER, datafile.getVariables().get("rate").getType()); + assertEquals(VariableType.DOUBLE, datafile.getVariables().get("ratio").getType()); + + Featurevisor sdk = Featurevisor.createFeaturevisor( + new Featurevisor.FeaturevisorOptions().datafile(datafile) + ); + assertEquals(2.5, sdk.getVariableDouble("rate")); + assertEquals(1.5, sdk.getVariableDouble("ratio")); + sdk.close(); + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/PortableV4ConformanceTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/PortableV4ConformanceTest.java new file mode 100644 index 0000000..4eaef25 --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/PortableV4ConformanceTest.java @@ -0,0 +1,147 @@ +package com.featurevisor.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.InputStream; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +final class PortableV4ConformanceTest { + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void executesEveryPortableV4CaseTwice() throws Exception { + JsonNode fixture; + try (InputStream stream = getClass().getResourceAsStream("/conformance/sdk-v4-cases.json")) { + assertNotNull(stream); + fixture = mapper.readTree(stream); + } + assertEquals(1, fixture.get("version").asInt()); + assertEquals(255, fixture.get("cases").size()); + + for (JsonNode testCase : fixture.get("cases")) { + JsonNode originalDatafile = testCase.get("datafile").deepCopy(); + DatafileContent datafile = mapper.treeToValue(testCase.get("datafile"), DatafileContent.class); + JsonNode variablesBeforeEvaluation = mapper.valueToTree(datafile.getVariables()); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(datafile) + .logLevel(FeaturevisorLogLevel.FATAL)); + Map context = testCase.has("context") + ? mapper.convertValue(testCase.get("context"), Map.class) : Collections.emptyMap(); + JsonNode expected = testCase.get("expected"); + Featurevisor.OverrideOptions options = options(testCase.path("options")); + + try { + for (int run = 0; run < 2; run++) { + String label = testCase.get("name").asText() + " run " + (run + 1); + Object getterValue = f.getVariable("subject", context, options); + assertEquals(expected.get("value"), mapper.valueToTree(getterValue), label + " getter"); + + Evaluation evaluation = f.evaluateVariable("subject", context, options); + assertEquals(expected.get("reason").asText(), evaluation.getReason(), label + " reason"); + assertEquals(expected.get("value"), mapper.valueToTree(evaluation.getVariableValue()), label + " value"); + JsonNode actual = mapper.valueToTree(evaluation); + expected.fields().forEachRemaining(field -> { + String key = field.getKey().equals("value") ? "variableValue" : field.getKey(); + assertEquals(field.getValue(), actual.get(key), label + " " + key); + }); + if (expected.has("variableOverrideIndex")) { + assertEquals(expected.get("variableOverrideIndex").asInt(), evaluation.getVariableOverrideIndex(), label); + } + if (expected.has("variableOverrideKey")) { + assertEquals(expected.get("variableOverrideKey").asText(), evaluation.getVariableOverrideKey(), label); + } + if (expected.has("variableOverridePath")) { + assertEquals(expected.get("variableOverridePath"), mapper.valueToTree(evaluation.getVariableOverridePath()), label); + } + if (expected.has("variableOverridePaths")) { + assertEquals(expected.get("variableOverridePaths"), mapper.valueToTree(evaluation.getVariableOverridePaths()), label); + } + } + assertEquals(variablesBeforeEvaluation, mapper.valueToTree(datafile.getVariables()), + testCase.get("name").asText() + " must not mutate variable inputs"); + assertEquals(originalDatafile, testCase.get("datafile"), + testCase.get("name").asText() + " must not mutate its fixture node"); + } finally { + f.close(); + } + } + } + + @Test + void firstMatchRepresentationsRemainEquivalentAcrossChildrenAndDatafileUpdates() throws Exception { + JsonNode fixture; + try (InputStream stream = getClass().getResourceAsStream("/conformance/sdk-v4-cases.json")) { + assertNotNull(stream); + fixture = mapper.readTree(stream); + } + DatafileContent precomputed = datafile(fixture, "first match precomputed parsed: child"); + DatafileContent runtime = datafile(fixture, "first match runtime parsed: child"); + Featurevisor root = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(precomputed) + .context(Map.of("region", "eu", "country", "be", "eligible", true, "denied", true)) + .logLevel(FeaturevisorLogLevel.FATAL)); + ChildInstance blockedChild = root.spawn(Map.of("country", "nl")); + ChildInstance allowedChild = root.spawn(Map.of("country", "nl"), + new Featurevisor.SpawnOptions().stickyVariables(Map.of("permission", true))); + Map parentValue = Map.of( + "stage", "parent", + "rows", List.of(Map.of("id", 1, "tags", List.of("parent"))), + "flags", List.of("a")); + Map childValue = Map.of( + "stage", "parent", + "rows", List.of( + Map.of("id", 0, "tags", List.of()), + Map.of("id", 1, "tags", List.of("parent", "child"))), + "flags", List.of("a")); + + assertValues(root, blockedChild, allowedChild, parentValue, childValue, false); + root.setDatafile(runtime, true); + assertValues(root, blockedChild, allowedChild, parentValue, childValue, true); + root.setDatafile(precomputed); + assertValues(root, blockedChild, allowedChild, parentValue, childValue, false); + } + + private DatafileContent datafile(JsonNode fixture, String name) throws Exception { + for (JsonNode testCase : fixture.get("cases")) { + if (name.equals(testCase.get("name").asText())) { + return mapper.treeToValue(testCase.get("datafile"), DatafileContent.class); + } + } + throw new IllegalArgumentException("Missing fixture case: " + name); + } + + private void assertValues( + Featurevisor root, + ChildInstance blockedChild, + ChildInstance allowedChild, + Map parentValue, + Map childValue, + boolean program + ) { + for (int run = 0; run < 2; run++) { + assertEquals(parentValue, root.getVariable("subject")); + assertEquals(parentValue, blockedChild.getVariable("subject")); + assertEquals(childValue, allowedChild.getVariable("subject")); + } + assertEquals( + program ? List.of(List.of("parent"), List.of("parent", "child")) : null, + allowedChild.evaluateVariable("subject").getVariableOverridePaths()); + } + + private Featurevisor.OverrideOptions options(JsonNode node) { + Featurevisor.OverrideOptions options = new Featurevisor.OverrideOptions(); + if (node.has("defaultVariationValue")) { + options.defaultVariationValue(node.get("defaultVariationValue").asText()); + } + if (node.has("defaultVariableValue")) { + options.defaultVariableValue(mapper.convertValue(node.get("defaultVariableValue"), Object.class)); + } + return options; + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/RequiredFeaturesReasonTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/RequiredFeaturesReasonTest.java new file mode 100644 index 0000000..98a618a --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/RequiredFeaturesReasonTest.java @@ -0,0 +1,39 @@ +package com.featurevisor.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +final class RequiredFeaturesReasonTest { + @Test void unmetFeatureReasonInEvaluationAndCallbacks() throws Exception { + List observed = new ArrayList<>(); + FeaturevisorModule module = new FeaturevisorModule("observe") + .afterEvaluation((evaluation, options) -> { + if ("subject".equals(evaluation.getFeatureKey())) observed.add(evaluation.getReason()); + return evaluation; + }); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(DatafileContent.fromJson(""" + {"schemaVersion":"2","revision":"reason","segments":{},"features":{ + "subject":{"bucketBy":"userId","requiredFeatures":["missing"],"traffic":[]} + }} + """)) + .logLevel(FeaturevisorLogLevel.FATAL) + .modules(List.of(module))); + try { + Evaluation evaluation = f.evaluateFlag("subject"); + assertEquals(Evaluation.REASON_CONDITIONS_UNMET, evaluation.getReason()); + assertFalse(evaluation.getEnabled()); + assertEquals(List.of("conditions_unmet"), observed); + var wire = new ObjectMapper().valueToTree(evaluation); + assertEquals("conditions_unmet", wire.get("reason").asText()); + assertEquals("missing", wire.get("conditions").get(0).get("feature").asText()); + assertThrows(NoSuchFieldException.class, () -> Evaluation.class.getField("REASON_REQUIRED")); + } finally { + f.close(); + } + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/SemverConformanceTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/SemverConformanceTest.java new file mode 100644 index 0000000..5a4c6b1 --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/SemverConformanceTest.java @@ -0,0 +1,34 @@ +package com.featurevisor.sdk; + +import static org.junit.jupiter.api.Assertions.*; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.InputStream; +import org.junit.jupiter.api.Test; + +final class SemverConformanceTest { + @Test + void portableComparisons() throws Exception { + JsonNode fixture; + try (InputStream stream = getClass().getResourceAsStream("/conformance/semver.json")) { + assertNotNull(stream); + fixture = new ObjectMapper().readTree(stream); + } + assertEquals("featurevisor.semver", fixture.get("kind").asText()); + assertEquals(1, fixture.get("version").asInt()); + assertEquals(44, fixture.get("cases").size()); + for (JsonNode entry : fixture.get("cases")) { + for (boolean reverse : new boolean[]{false, true}) { + String a = entry.get(reverse ? "right" : "left").asText(); + String b = entry.get(reverse ? "left" : "right").asText(); + String label = entry.get("name").asText() + " reverse=" + reverse; + if (entry.get("order").isNull()) { + assertThrows(RuntimeException.class, () -> CompareVersions.compareVersions(a, b), label); + } else { + int expected = entry.get("order").asInt() * (reverse ? -1 : 1); + assertEquals(expected, CompareVersions.compareVersions(a, b), label); + } + } + } + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/SetDatafileResultTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/SetDatafileResultTest.java new file mode 100644 index 0000000..dd23237 --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/SetDatafileResultTest.java @@ -0,0 +1,160 @@ +package com.featurevisor.sdk; + +import org.junit.jupiter.api.Test; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import static org.junit.jupiter.api.Assertions.*; + +class SetDatafileResultTest { + private static final String ACCEPTED = """ + {"schemaVersion":"2","revision":"accepted","segments":{}, + "features":{"flag":{"key":"flag","hash":"stable","bucketBy":"userId", + "traffic":[{"key":"all","segments":"*","percentage":100000,"enabled":true}]}}, + "variables":{"colour":{"key":"colour","hash":"stable","type":"string","defaultValue":"blue"}}} + """; + + private static boolean set(Featurevisor sdk, Object input, Boolean replace) { + if (input instanceof String) { + return replace == null ? sdk.setDatafile((String) input) : sdk.setDatafile((String) input, replace); + } + return replace == null ? sdk.setDatafile((DatafileContent) input) : sdk.setDatafile((DatafileContent) input, replace); + } + + @Test void acceptanceAcrossOverloadsAndModes() throws Exception { + for (Object input : new Object[]{ACCEPTED, DatafileContent.fromJson(ACCEPTED)}) { + for (Boolean replace : new Boolean[]{null, false, true}) { + List diagnostics = new ArrayList<>(); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafileString(ACCEPTED.replace("flag", "retainedFlag").replace("colour", "retainedColour").replace("accepted", "initial")) + .onDiagnostic(diagnostics::add)); + try { + List events = new ArrayList<>(); + sdk.on(FeaturevisorEventName.DATAFILE_SET, events::add); + assertTrue(set(sdk, input, replace)); + assertTrue(set(sdk, input, replace), "Unchanged datafiles are still accepted"); + assertEquals(2, events.size()); + FeaturevisorEventDetails unchanged = events.get(1); + assertEquals(false, unchanged.get("revisionChanged")); + assertEquals(Boolean.TRUE.equals(replace), unchanged.get("replaced")); + assertEquals(List.of(), unchanged.get("features")); + assertEquals(List.of(), unchanged.get("variables")); + assertEquals("datafile_set", diagnostics.get(diagnostics.size() - 1).getCode()); + assertTrue(sdk.isEnabled("flag")); + assertEquals("blue", sdk.getVariableString("colour")); + assertEquals(!Boolean.TRUE.equals(replace), sdk.getFeature("retainedFlag") != null); + assertEquals(!Boolean.TRUE.equals(replace), sdk.getVariableString("retainedColour") != null); + + sdk.close(); + int before = diagnostics.size(); + assertFalse(set(sdk, input, replace)); + assertFalse(set(sdk, "{", replace)); + assertFalse(sdk.setDatafile((String) null)); + assertFalse(sdk.setDatafile((String) null, true)); + assertFalse(sdk.setDatafile((DatafileContent) null)); + assertFalse(sdk.setDatafile((DatafileContent) null, true)); + assertEquals(before, diagnostics.size()); + assertEquals(2, events.size()); + assertEquals("accepted", sdk.getRevision()); + } finally { + sdk.close(); + } + } + } + } + + @Test void rejectionPreservesStateDiagnosticsAndEventBaseline() throws Exception { + for (Object input : new Object[]{"{", "null", "[]", "{}", null, new DatafileContent(), + "{\"schemaVersion\":\"2\",\"revision\":\"bad\",\"segments\":null,\"features\":{}}"}) { + for (Boolean replace : new Boolean[]{null, false, true}) { + List diagnostics = new ArrayList<>(); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(DatafileContent.fromJson(ACCEPTED)).onDiagnostic(diagnostics::add)); + try { + diagnostics.clear(); + List events = new ArrayList<>(); + AtomicInteger errors = new AtomicInteger(); + sdk.on(FeaturevisorEventName.DATAFILE_SET, events::add); + sdk.on(FeaturevisorEventName.ERROR, event -> { + errors.incrementAndGet(); + assertEquals("invalid_datafile", ((FeaturevisorDiagnostic) event.get("diagnostic")).getCode()); + }); + assertFalse(set(sdk, input, replace)); + assertEquals(1, diagnostics.size()); + assertEquals("invalid_datafile", diagnostics.get(0).getCode()); + assertNotNull(diagnostics.get(0).getOriginalError()); + assertEquals(1, errors.get()); + assertTrue(events.isEmpty()); + assertEquals("accepted", sdk.getRevision()); + assertTrue(sdk.isEnabled("flag")); + assertEquals("blue", sdk.getVariableString("colour")); + + assertTrue(set(sdk, ACCEPTED, replace)); + assertEquals(1, events.size()); + assertEquals("accepted", events.get(0).get("previousRevision")); + assertEquals(false, events.get(0).get("revisionChanged")); + assertEquals(List.of(), events.get(0).get("features")); + assertEquals(List.of(), events.get(0).get("variables")); + } finally { + sdk.close(); + } + } + } + } + + @Test void nullStringsAreRejectedByBothOverloads() { + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().logLevel(FeaturevisorLogLevel.FATAL)); + try { + assertFalse(sdk.setDatafile((String) null)); + assertFalse(sdk.setDatafile((String) null, true)); + } finally { + sdk.close(); + } + } + + @Test void callbackFailuresDoNotChangeAcceptance() { + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .onDiagnostic(diagnostic -> { throw new IllegalStateException("diagnostic"); })); + try { + AtomicInteger calls = new AtomicInteger(); + sdk.on(FeaturevisorEventName.DATAFILE_SET, event -> { throw new IllegalStateException("listener"); }); + sdk.on(FeaturevisorEventName.DATAFILE_SET, event -> calls.incrementAndGet()); + sdk.on(FeaturevisorEventName.ERROR, event -> { throw new IllegalStateException("error listener"); }); + assertTrue(sdk.setDatafile(ACCEPTED)); + assertFalse(sdk.setDatafile("{")); + assertEquals(1, calls.get()); + assertEquals("accepted", sdk.getRevision()); + } finally { + sdk.close(); + } + } + + @Test void resultBelongsToItsOwnCallWhenListenersReenterOrClose() { + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().logLevel(FeaturevisorLogLevel.FATAL)); + sdk.on(FeaturevisorEventName.DATAFILE_SET, event -> { + assertFalse(sdk.setDatafile("{")); + sdk.close(); + }); + assertTrue(sdk.setDatafile(ACCEPTED)); + assertFalse(sdk.setDatafile(ACCEPTED)); + + Featurevisor recovered = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().logLevel(FeaturevisorLogLevel.FATAL)); + try { + recovered.on(FeaturevisorEventName.ERROR, event -> assertTrue(recovered.setDatafile(ACCEPTED))); + assertFalse(recovered.setDatafile("{")); + assertEquals("accepted", recovered.getRevision()); + } finally { + recovered.close(); + } + } + + @Test void diagnosticCallbackCanCloseWithoutChangingAcceptance() { + Featurevisor[] reference = new Featurevisor[1]; + reference[0] = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().onDiagnostic(diagnostic -> { + if ("datafile_set".equals(diagnostic.getCode())) reference[0].close(); + })); + assertTrue(reference[0].setDatafile(ACCEPTED)); + assertFalse(reference[0].setDatafile(ACCEPTED)); + assertEquals("accepted", reference[0].getRevision()); + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/StackDepthTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/StackDepthTest.java new file mode 100644 index 0000000..a6d2139 --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/StackDepthTest.java @@ -0,0 +1,84 @@ +package com.featurevisor.sdk; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +final class StackDepthTest { + private static String chain(int length) { + StringBuilder features = new StringBuilder(); + for (int index = 0; index < length; index++) { + if (index > 0) features.append(','); + String conditions = index == 0 ? "\"*\"" : "\"[{\\\"feature\\\":\\\"f" + (index - 1) + "\\\"}]\""; + features.append("\"f").append(index).append("\":{\"bucketBy\":\"userId\",\"traffic\":[{\"key\":\"all\",\"conditions\":") + .append(conditions).append(",\"percentage\":100000}]}"); + } + return "{\"schemaVersion\":\"4\",\"revision\":\"depth\",\"segments\":{},\"features\":{" + features + "}}"; + } + + private static Featurevisor create(List errors, FeaturevisorModule... modules) { + return Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafileString(chain(300)).logLevel(FeaturevisorLogLevel.ERROR).modules(List.of(modules)) + .onDiagnostic(diagnostic -> { + if (diagnostic.getLevel() == FeaturevisorLogLevel.ERROR) { + synchronized (errors) { errors.add(diagnostic.getCode()); } + } + })); + } + + private static Object onThread(long stackSize, java.util.function.Supplier body) throws Exception { + AtomicReference result = new AtomicReference<>(); + Thread thread = new Thread(null, () -> { + try { result.set(body.get()); } catch (Throwable thrown) { result.set(thrown); } + }, "small-stack", stackSize); + thread.start(); + thread.join(); + return result.get(); + } + + @Test void smallThreadStacksNeverThrowFromGetters() throws Exception { + List errors = new ArrayList<>(); + Featurevisor f = create(errors); + try { + Map context = Map.of("userId", "u"); + assertEquals(Boolean.TRUE, onThread(512 * 1024, () -> f.isEnabled("f32", context))); + // Whether 256 handoffs fit in a 256 KB stack depends on the platform, JDK and JIT. + // Either way the getter returns a value, and a failure is reported once. + Object small = onThread(256 * 1024, () -> f.isEnabled("f256", context)); + assertTrue(small instanceof Boolean, () -> "getter threw " + small); + List expected = Boolean.TRUE.equals(small) ? List.of() : List.of("evaluation_error"); + assertEquals(expected, errors); + assertTrue(f.isEnabled("f256", context)); + assertEquals(expected, errors); + } finally { f.close(); } + } + + @Test void stackOverflowInADependencyFailsTheWholeCallOnce() { + List errors = new ArrayList<>(); + // Overflow where a deep chain would, at its innermost dependency, on every platform. + FeaturevisorModule overflow = new FeaturevisorModule("overflow").beforeEvaluation(options -> { + if ("f0".equals(options.getFeatureKey())) throw new StackOverflowError(); + return options; + }); + Featurevisor f = create(errors, overflow); + try { + Map context = Map.of("userId", "u"); + assertFalse(assertDoesNotThrow(() -> f.isEnabled("f256", context))); + assertEquals(List.of("evaluation_error"), errors); + } finally { f.close(); } + } + + @Test void dependencyFailuresReportOnceForEachPublicCall() { + List errors = new ArrayList<>(); + Featurevisor f = create(errors); + try { + Map context = Map.of("userId", "u"); + assertFalse(f.isEnabled("f299", context)); + assertNull(f.getVariation("f299", context)); + assertEquals(List.of("evaluation_error", "evaluation_error"), errors); + } finally { f.close(); } + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/StatefulConformanceTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/StatefulConformanceTest.java new file mode 100644 index 0000000..fa8dea7 --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/StatefulConformanceTest.java @@ -0,0 +1,223 @@ +package com.featurevisor.sdk; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class StatefulConformanceTest { + private final ObjectMapper json = new ObjectMapper(); + + @Test + void runsEveryStatefulConformanceCase() throws Exception { + JsonNode fixture; + try (InputStream stream = getClass().getResourceAsStream("/conformance/sdk-stateful.json")) { + assertNotNull(stream); + fixture = json.readTree(stream); + } + assertEquals("featurevisor.sdk-stateful", fixture.get("kind").asText()); + assertEquals(1, fixture.get("schemaVersion").asInt()); + assertTrue(fixture.get("cases").isArray()); + assertFalse(fixture.get("cases").isEmpty()); + + for (JsonNode testCase : fixture.get("cases")) { + runCase(testCase); + } + } + + private void runCase(JsonNode testCase) throws Exception { + List> events = new ArrayList<>(); + List diagnostics = new ArrayList<>(); + DatafileContent initial = DatafileContent.fromJson( + json.writeValueAsString(testCase.at("/initial/datafile")) + ); + Featurevisor root = Featurevisor.createFeaturevisor( + new Featurevisor.FeaturevisorOptions() + .datafile(initial) + .context(map(testCase.at("/initial/context"))) + .onDiagnostic(diagnostics::add) + .logLevel(FeaturevisorLogLevel.DEBUG) + ); + Map targets = new LinkedHashMap<>(); + targets.put("root", root); + subscribe(root, "root", events); + try { + for (JsonNode operation : testCase.get("operations")) { + events.clear(); + diagnostics.clear(); + execute(operation, targets, events); + assertOperation(testCase.get("name").asText(), operation, targets, events, diagnostics); + } + } finally { + for (Object target : targets.values()) { + if (target instanceof ChildInstance) ((ChildInstance) target).close(); + } + root.close(); + } + } + + private void execute(JsonNode operation, Map targets, + List> events) throws Exception { + String action = operation.get("action").asText(); + if (action.equals("setDatafile")) { + Featurevisor root = (Featurevisor) targets.get(operation.get("target").asText()); + boolean replace = operation.path("replace").asBoolean(false); + JsonNode raw = operation.get("datafile"); + if (raw.isTextual()) { + root.setDatafile(raw.asText(), replace); + } else { + root.setDatafile(DatafileContent.fromJson(json.writeValueAsString(raw)), replace); + } + return; + } + if (action.equals("spawn")) { + Featurevisor parent = (Featurevisor) targets.get(operation.get("parent").asText()); + JsonNode options = operation.path("options"); + Featurevisor.SpawnOptions spawnOptions = new Featurevisor.SpawnOptions() + .stickyFeatures(map(options.get("stickyFeatures"))) + .stickyVariables(map(options.get("stickyVariables"))); + ChildInstance child = parent.spawn(map(operation.get("context")), spawnOptions); + String targetName = operation.get("target").asText(); + targets.put(targetName, child); + subscribe(child, targetName, events); + return; + } + Object target = targets.get(operation.get("target").asText()); + boolean replace = operation.path("replace").asBoolean(false); + if (action.equals("setStickyFeatures")) { + setStickyFeatures(target, map(operation.get("stickyFeatures")), replace); + } else if (action.equals("setStickyVariables")) { + setStickyVariables(target, map(operation.get("stickyVariables")), replace); + } else { + fail("Unknown stateful action " + action); + } + } + + private void assertOperation(String caseName, JsonNode operation, Map targets, + List> events, List diagnostics) { + JsonNode expect = operation.get("expect"); + if (expect.has("error")) { + String code = expect.at("/error/code").asText(); + assertTrue(diagnostics.stream().anyMatch(value -> code.equals(value.getCode())), + caseName + ": expected diagnostic " + code); + } else { + assertTrue(diagnostics.stream().noneMatch(value -> value.getLevel() == FeaturevisorLogLevel.ERROR), + caseName + ": unexpected error diagnostic"); + } + + JsonNode expectedEvents = normalize(expect.get("events")); + JsonNode actualEvents = normalize(json.valueToTree(events)); + assertEquals(expectedEvents, actualEvents, caseName + ": events for " + operation.get("action")); + + for (JsonNode result : expect.get("results")) { + Object target = targets.get(result.get("target").asText()); + Object actual = method(target, result.get("method").asText(), result.get("args")); + assertEquals(normalize(result.get("value")), normalize(json.valueToTree(actual)), + caseName + ": " + result.get("target").asText() + "." + result.get("method").asText()); + } + } + + private Object method(Object target, String method, JsonNode args) { + if (method.equals("getRevision")) return ((Featurevisor) target).getRevision(); + if (method.equals("isEnabled")) { + String feature = args.get(0).asText(); + return target instanceof Featurevisor + ? ((Featurevisor) target).isEnabled(feature) + : ((ChildInstance) target).isEnabled(feature); + } + if (method.equals("getVariable")) { + if (args.size() == 1) { + String variable = args.get(0).asText(); + return target instanceof Featurevisor + ? ((Featurevisor) target).getVariable(variable) + : ((ChildInstance) target).getVariable(variable); + } + String feature = args.get(0).asText(); + String variable = args.get(1).asText(); + return target instanceof Featurevisor + ? ((Featurevisor) target).getVariable(feature, variable) + : ((ChildInstance) target).getVariable(feature, variable); + } + throw new AssertionError("Unknown result method " + method); + } + + private void subscribe(Featurevisor target, String name, List> events) { + for (FeaturevisorEventName event : new FeaturevisorEventName[] { + FeaturevisorEventName.DATAFILE_SET, + FeaturevisorEventName.STICKY_FEATURES_SET, + FeaturevisorEventName.STICKY_VARIABLES_SET, + }) { + target.on(event, details -> events.add(event(name, event, details))); + } + } + + private void subscribe(ChildInstance target, String name, List> events) { + for (FeaturevisorEventName event : new FeaturevisorEventName[] { + FeaturevisorEventName.STICKY_FEATURES_SET, + FeaturevisorEventName.STICKY_VARIABLES_SET, + }) { + target.on(event, details -> events.add(event(name, event, details))); + } + } + + private Map event(String target, FeaturevisorEventName name, + FeaturevisorEventDetails details) { + Map value = new LinkedHashMap<>(); + value.put("target", target); + value.put("name", name.getValue()); + value.put("details", new LinkedHashMap<>(details)); + return value; + } + + private void setStickyFeatures(Object target, Map value, boolean replace) { + if (target instanceof Featurevisor) ((Featurevisor) target).setStickyFeatures(value, replace); + else ((ChildInstance) target).setStickyFeatures(value, replace); + } + + private void setStickyVariables(Object target, Map value, boolean replace) { + if (target instanceof Featurevisor) ((Featurevisor) target).setStickyVariables(value, replace); + else ((ChildInstance) target).setStickyVariables(value, replace); + } + + private Map map(JsonNode value) { + if (value == null || value.isMissingNode() || value.isNull()) return new LinkedHashMap<>(); + return json.convertValue(value, new TypeReference>() {}); + } + + private JsonNode normalize(JsonNode value) { + JsonNode copy = value == null ? json.nullNode() : value.deepCopy(); + normalizeKeySets(copy); + return copy; + } + + private void normalizeKeySets(JsonNode value) { + if (value.isObject()) { + value.fields().forEachRemaining(entry -> { + if ((entry.getKey().equals("features") || entry.getKey().equals("variables")) + && entry.getValue().isArray()) { + List keys = new ArrayList<>(); + entry.getValue().forEach(item -> keys.add(item.asText())); + Collections.sort(keys); + ArrayNode sorted = json.createArrayNode(); + keys.forEach(sorted::add); + ((ObjectNode) value).set(entry.getKey(), sorted); + } else { + normalizeKeySets(entry.getValue()); + } + }); + } else if (value.isArray()) { + value.forEach(this::normalizeKeySets); + } + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/V4RuntimeTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/V4RuntimeTest.java new file mode 100644 index 0000000..6c9d4fa --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/V4RuntimeTest.java @@ -0,0 +1,488 @@ +package com.featurevisor.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +final class V4RuntimeTest { + private final ObjectMapper mapper = new ObjectMapper(); + + private Featurevisor instance(String json, FeaturevisorModule... modules) throws Exception { + return Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(mapper.readValue(json, DatafileContent.class)) + .modules(List.of(modules)) + .logLevel(FeaturevisorLogLevel.FATAL)); + } + + @Test void nullOperatorsDistinguishMissingFromNull() { + assertTrue(Conditions.allConditionsAreMatched( + Map.of("attribute", "value", "operator", "isNull"), nullValue())); + assertFalse(Conditions.allConditionsAreMatched( + Map.of("attribute", "missing", "operator", "isNull"), Map.of())); + assertFalse(Conditions.allConditionsAreMatched( + Map.of("attribute", "value", "operator", "notNull"), nullValue())); + assertFalse(Conditions.allConditionsAreMatched( + Map.of("attribute", "missing", "operator", "notNull"), Map.of())); + } + + @Test void emptyRequiredVariablesMatchOnlySupportedContainerContents() throws Exception { + Featurevisor f = instance(""" + {"schemaVersion":"2","revision":"empty-requirements","segments":{},"features":{},"variables":{ + "emptyString":{"type":"string","defaultValue":""}, + "whitespace":{"type":"string","defaultValue":" "}, + "emptyArray":{"type":"array","defaultValue":[]}, + "arrayWithNull":{"type":"array","defaultValue":[null]}, + "emptyObject":{"type":"object","defaultValue":{}}, + "objectWithNull":{"type":"object","defaultValue":{"value":null}}, + "nullValue":{"type":"object","defaultValue":null}, + "number":{"type":"integer","defaultValue":0}, + "boolean":{"type":"boolean","defaultValue":false}, + "nested":{"type":"object","defaultValue":{"items":[]}}, + "emptyStringGate":{"type":"string","defaultValue":"matched","disabledValue":"miss","conditions":[{"variable":"emptyString","operator":"empty"}]}, + "whitespaceGate":{"type":"string","defaultValue":"matched","disabledValue":"miss","conditions":[{"variable":"whitespace","operator":"notEmpty"}]}, + "emptyArrayGate":{"type":"string","defaultValue":"matched","disabledValue":"miss","conditions":[{"variable":"emptyArray","operator":"empty"}]}, + "arrayWithNullGate":{"type":"string","defaultValue":"matched","disabledValue":"miss","conditions":[{"variable":"arrayWithNull","operator":"notEmpty"}]}, + "emptyObjectGate":{"type":"string","defaultValue":"matched","disabledValue":"miss","conditions":[{"variable":"emptyObject","operator":"empty"}]}, + "objectWithNullGate":{"type":"string","defaultValue":"matched","disabledValue":"miss","conditions":[{"variable":"objectWithNull","operator":"notEmpty"}]}, + "nestedGate":{"type":"string","defaultValue":"matched","disabledValue":"miss","conditions":[{"variable":"nested","path":["items"],"operator":"empty"}]}, + "nullEmptyGate":{"type":"string","defaultValue":"matched","disabledValue":"miss","conditions":[{"variable":"nullValue","operator":"empty"}]}, + "nullNotEmptyGate":{"type":"string","defaultValue":"matched","disabledValue":"miss","conditions":[{"variable":"nullValue","operator":"notEmpty"}]}, + "missingGate":{"type":"string","defaultValue":"matched","disabledValue":"miss","conditions":[{"variable":"missing","operator":"empty"}]}, + "numberGate":{"type":"string","defaultValue":"matched","disabledValue":"miss","conditions":[{"variable":"number","operator":"notEmpty"}]}, + "booleanGate":{"type":"string","defaultValue":"matched","disabledValue":"miss","conditions":[{"variable":"boolean","operator":"empty"}]} + }} + """); + + for (String key : List.of("emptyStringGate", "whitespaceGate", "emptyArrayGate", "arrayWithNullGate", + "emptyObjectGate", "objectWithNullGate", "nestedGate")) { + assertEquals("matched", f.getVariable(key), key); + } + for (String key : List.of("nullEmptyGate", "nullNotEmptyGate", "missingGate", "numberGate", "booleanGate")) { + assertEquals("miss", f.getVariable(key), key); + assertEquals(Evaluation.REASON_CONDITIONS_UNMET, f.evaluateVariable(key).getReason(), key); + } + } + + private Map nullValue() { + java.util.HashMap value = new java.util.HashMap<>(); + value.put("value", null); + return value; + } + + @Test void presentEmptyProgramSuppressesLegacyOverridesAndEmptyContainersRemainValues() throws Exception { + DatafileContent datafile = mapper.readValue(""" + {"schemaVersion":"2","revision":"empty-program","segments":{},"features":{},"variables":{ + "source":{"type":"boolean","defaultValue":true}, + "subject":{"type":"object","defaultValue":{"default":true}, + "overrides":[{"segments":"*","value":{"wrong":true},"conditions":[ + {"variable":"source","operator":"equals","value":true} + ]}],"overrideProgram":[]}, + "emptyArray":{"type":"array","defaultValue":[]}, + "emptyObject":{"type":"object","defaultValue":{}} + }} + """, DatafileContent.class); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(datafile) + .logLevel(FeaturevisorLogLevel.FATAL)); + + assertEquals(Map.of("default", true), f.getVariable("subject")); + Evaluation evaluation = f.evaluateVariable("subject"); + assertEquals(Evaluation.REASON_VARIABLE_DEFAULT, evaluation.getReason()); + assertNull(evaluation.getVariableOverrideIndex()); + assertNull(evaluation.getVariableOverrideKey()); + assertNull(evaluation.getVariableOverridePath()); + assertNull(evaluation.getVariableOverridePaths()); + assertEquals(List.of(), f.getVariableArray("emptyArray", Map.of(), null)); + assertEquals(Map.of(), f.getVariableObject("emptyObject", Map.of(), null)); + + Dependencies.Index dependencies = Dependencies.create(datafile); + assertFalse(dependencies.dependents[1].getOrDefault("source", java.util.Set.of()).contains("1:subject")); + } + + @Test void legacyRequiredFieldAndLegacyKeyShapeAreIgnored() throws Exception { + Featurevisor f = instance(""" + {"schemaVersion":"2","revision":"legacy","segments":{},"features":{ + "off":{"bucketBy":"userId","traffic":[]}, + "subject":{"bucketBy":"userId","required":[{"key":"off"}], + "traffic":[{"key":"all","segments":"*","percentage":100000}]} + }} + """); + assertTrue(f.isEnabled("subject")); + } + + @Test void accumulationRunsOneModuleEnvelopeAndPublishesCompleteMetadata() throws Exception { + AtomicInteger before = new AtomicInteger(); + AtomicInteger after = new AtomicInteger(); + FeaturevisorModule module = new FeaturevisorModule("observer") + .beforeEvaluation(options -> { before.incrementAndGet(); return options; }) + .afterEvaluation((evaluation, options) -> { after.incrementAndGet(); return evaluation; }); + Featurevisor f = instance(""" + {"schemaVersion":"2","revision":"modules","segments":{},"features":{},"variables":{ + "subject":{"type":"object","defaultValue":{},"overrideProgram":[ + {"key":"one","keyPath":["one"],"segments":"*","operations":[{"op":"set","path":["one"],"value":1}],"onMatch":1,"onMiss":1}, + {"key":"two","keyPath":["two"],"segments":"*","operations":[{"op":"set","path":["two"],"value":2}],"onMatch":2,"onMiss":2} + ]} + }} + """, module); + Evaluation evaluation = f.evaluateVariable("subject"); + assertEquals(Map.of("one", 1, "two", 2), evaluation.getVariableValue()); + assertEquals(List.of(List.of("one"), List.of("two")), evaluation.getVariableOverridePaths()); + assertEquals("two", evaluation.getVariableOverrideKey()); + assertEquals(1, evaluation.getVariableOverrideIndex()); + assertEquals(1, before.get()); + assertEquals(1, after.get()); + } + + @Test void childStickyValuesDriveRequirementsWithoutLeakingParentDefaults() throws Exception { + Featurevisor f = instance(""" + {"schemaVersion":"2","revision":"child","segments":{},"features":{},"variables":{ + "source":{"type":"object","defaultValue":{"enabled":false}}, + "subject":{"type":"string","defaultValue":"yes","disabledValue":"no","conditions":[ + {"variable":"source","path":["enabled"],"operator":"equals","value":true} + ]} + }} + """); + ChildInstance child = f.spawn(Map.of(), new Featurevisor.SpawnOptions() + .stickyVariables(Map.of("source", Map.of("enabled", true)))); + assertEquals("no", f.getVariable("subject")); + assertEquals("yes", child.getVariable("subject")); + assertEquals("yes", child.getVariable("subject", Map.of(), + new Featurevisor.OverrideOptions().defaultVariableValue("caller"))); + } + + @Test void defaultChildStickyMapsAreIsolatedFromParent() throws Exception { + Featurevisor f = instance(""" + {"schemaVersion":"2","revision":"child-defaults","segments":{},"features":{ + "flag":{"bucketBy":"userId","force":[{"conditions":"*","enabled":false}]} + },"variables":{ + "source":{"type":"boolean","defaultValue":false}, + "subject":{"type":"string","defaultValue":"yes","disabledValue":"no","conditions":[ + {"variable":"source","operator":"equals","value":true} + ]} + }} + """); + f.setStickyFeatures(Map.of("flag", Map.of("enabled", true))); + f.setStickyVariables(Map.of("source", true)); + ChildInstance child = f.spawn(); + + assertTrue(f.isEnabled("flag")); + assertEquals("yes", f.getVariable("subject")); + assertFalse(child.isEnabled("flag")); + assertEquals("no", child.getVariable("subject")); + } + + @Test void mixedDependencyGraphPropagatesAcrossEveryNamespace() throws Exception { + Featurevisor f = instance(""" + {"schemaVersion":"2","revision":"one","segments":{},"features":{ + "fromVariable":{"hash":"fv","bucketBy":"userId","conditions":[{"variable":"source","operator":"equals","value":true}],"traffic":[]}, + "fromVariableAgain":{"hash":"fva","bucketBy":"userId","conditions":[{"variable":"middle","operator":"equals","value":true}],"traffic":[]} + },"variables":{ + "source":{"hash":"source-1","type":"boolean","defaultValue":true}, + "middle":{"hash":"middle","type":"boolean","defaultValue":true,"requiredFeatures":["fromVariable"]}, + "program":{"hash":"program","type":"boolean","defaultValue":true,"overrideProgram":[ + {"key":"branch","keyPath":["branch"],"conditions":[{"or":[{"variable":"middle","operator":"equals","value":true},{"variable":"unused","operator":"equals","value":true}]}],"operations":[{"op":"set","path":[],"value":true}],"onMatch":1,"onMiss":1} + ]} + }} + """); + FeaturevisorEventDetails[] event = new FeaturevisorEventDetails[1]; + f.on(FeaturevisorEventName.DATAFILE_SET, details -> event[0] = details); + f.setDatafile(mapper.readValue(""" + {"schemaVersion":"2","revision":"two","segments":{},"features":{},"variables":{ + "source":{"hash":"source-2","type":"boolean","defaultValue":false} + }} + """, DatafileContent.class)); + assertNotNull(event[0]); + assertTrue(((List) event[0].get("features")).containsAll(List.of("fromVariable", "fromVariableAgain"))); + assertTrue(((List) event[0].get("variables")).containsAll(List.of("source", "middle", "program"))); + } + + @Test void hashlessAndEmptyHashEntitiesAlwaysNotifyWithMixedDependencies() throws Exception { + Featurevisor f = instance(""" + {"schemaVersion":"2","revision":"hash-one","segments":{},"features":{ + "featureHashlessSame":{"bucketBy":"userId","traffic":[]}, + "featureHashlessChanged":{"bucketBy":"userId","force":[{"conditions":"*","enabled":true}]}, + "featureEmptySame":{"hash":"","bucketBy":"userId","traffic":[]}, + "featureStable":{"hash":"stable","bucketBy":"userId","traffic":[]}, + "dependentFeature":{"hash":"dependent","bucketBy":"userId","requiredFeatures":["featureHashlessSame"],"traffic":[]}, + "featureFromVariable":{"hash":"feature-variable","bucketBy":"userId","conditions":[{"variable":"variableHashlessChanged","operator":"equals","value":true}],"traffic":[]} + },"variables":{ + "variableHashlessSame":{"type":"boolean","defaultValue":true}, + "variableHashlessChanged":{"type":"boolean","defaultValue":true}, + "variableEmptySame":{"hash":"","type":"boolean","defaultValue":true}, + "variableStable":{"hash":"stable","type":"boolean","defaultValue":true}, + "variableFromFeature":{"hash":"variable-feature","type":"boolean","defaultValue":true,"requiredFeatures":["featureHashlessSame"]} + }} + """); + FeaturevisorEventDetails[] event = new FeaturevisorEventDetails[1]; + f.on(FeaturevisorEventName.DATAFILE_SET, details -> event[0] = details); + f.setDatafile(mapper.readValue(""" + {"schemaVersion":"2","revision":"hash-two","segments":{},"features":{ + "featureHashlessSame":{"bucketBy":"userId","traffic":[]}, + "featureHashlessChanged":{"bucketBy":"userId","force":[{"conditions":"*","enabled":false}]}, + "featureEmptySame":{"hash":"","bucketBy":"userId","traffic":[]}, + "featureStable":{"hash":"stable","bucketBy":"userId","traffic":[]}, + "dependentFeature":{"hash":"dependent","bucketBy":"userId","requiredFeatures":["featureHashlessSame"],"traffic":[]}, + "featureFromVariable":{"hash":"feature-variable","bucketBy":"userId","conditions":[{"variable":"variableHashlessChanged","operator":"equals","value":true}],"traffic":[]} + },"variables":{ + "variableHashlessSame":{"type":"boolean","defaultValue":true}, + "variableHashlessChanged":{"type":"boolean","defaultValue":false}, + "variableEmptySame":{"hash":"","type":"boolean","defaultValue":true}, + "variableStable":{"hash":"stable","type":"boolean","defaultValue":true}, + "variableFromFeature":{"hash":"variable-feature","type":"boolean","defaultValue":true,"requiredFeatures":["featureHashlessSame"]} + }} + """, DatafileContent.class), true); + + List features = (List) event[0].get("features"); + List variables = (List) event[0].get("variables"); + assertTrue(features.containsAll(List.of("featureHashlessSame", "featureHashlessChanged", "featureEmptySame", "dependentFeature", "featureFromVariable"))); + assertFalse(features.contains("featureStable")); + assertTrue(variables.containsAll(List.of("variableHashlessSame", "variableHashlessChanged", "variableEmptySame", "variableFromFeature"))); + assertFalse(variables.contains("variableStable")); + } + + @Test void reentrantCallbacksAndConcurrentCyclesRemainIsolated() throws Exception { + AtomicBoolean reenter = new AtomicBoolean(true); + Featurevisor[] holder = new Featurevisor[1]; + FeaturevisorModule module = new FeaturevisorModule("reentrant") + .beforeEvaluation(options -> { + if ("ok".equals(options.getVariableKey()) && reenter.getAndSet(false)) { + assertEquals(Boolean.TRUE, holder[0].getVariable("helper")); + } + return options; + }); + holder[0] = instance(""" + {"schemaVersion":"2","revision":"concurrent","segments":{},"features":{},"variables":{ + "helper":{"type":"boolean","defaultValue":true}, + "ok":{"type":"boolean","defaultValue":true}, + "cycle":{"type":"boolean","defaultValue":true,"conditions":[{"variable":"cycle","operator":"isNull"}]} + }} + """, module); + assertEquals(Boolean.TRUE, holder[0].getVariable("ok")); + + var pool = Executors.newFixedThreadPool(2); + try { + List> calls = List.of( + () -> holder[0].evaluateVariable("cycle").getReason(), + () -> String.valueOf(holder[0].getVariable("ok"))); + List> results = pool.invokeAll(calls); + assertEquals("error", results.get(0).get()); + assertEquals("true", results.get(1).get()); + } finally { + pool.shutdownNow(); + } + assertEquals(Boolean.TRUE, holder[0].getVariable("ok")); + } + + @Test void sourceGetterErrorsBecomeNullButCyclesStillPropagate() throws Exception { + FeaturevisorModule module = new FeaturevisorModule("throw-source") + .beforeEvaluation(options -> { + if ("source".equals(options.getVariableKey())) throw new IllegalStateException("source failed"); + return options; + }); + Featurevisor f = instance(""" + {"schemaVersion":"2","revision":"getter-error","segments":{},"features":{},"variables":{ + "source":{"type":"boolean","defaultValue":true}, + "subject":{"type":"string","defaultValue":"matched","conditions":[ + {"variable":"source","operator":"isNull"} + ]}, + "emptySubject":{"type":"string","defaultValue":"matched","disabledValue":"miss","conditions":[ + {"variable":"source","operator":"empty"} + ]}, + "notEmptySubject":{"type":"string","defaultValue":"matched","disabledValue":"miss","conditions":[ + {"variable":"source","operator":"notEmpty"} + ]}, + "cycle":{"type":"boolean","defaultValue":true,"conditions":[ + {"variable":"cycle","operator":"isNull"} + ]} + }} + """, module); + + Evaluation subject = f.evaluateVariable("subject"); + assertEquals(Evaluation.REASON_VARIABLE_DEFAULT, subject.getReason()); + assertEquals("matched", subject.getVariableValue()); + assertEquals("miss", f.getVariable("emptySubject")); + assertEquals("miss", f.getVariable("notEmptySubject")); + assertEquals(Evaluation.REASON_ERROR, f.evaluateVariable("cycle").getReason()); + } + + @Test void malformedJsonGettersReportOnceAndRemainNullForRequirements() throws Exception { + List diagnostics = new ArrayList<>(); + AtomicInteger globalCalls = new AtomicInteger(); + AtomicInteger featureCalls = new AtomicInteger(); + FeaturevisorModule module = new FeaturevisorModule("json-count") + .beforeEvaluation(options -> { + if ("globalBad".equals(options.getVariableKey())) globalCalls.incrementAndGet(); + if ("featureBad".equals(options.getVariableKey())) featureCalls.incrementAndGet(); + return options; + }); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(mapper.readValue(""" + {"schemaVersion":"2","revision":"json-errors","segments":{},"features":{ + "sourceFeature":{"bucketBy":"userId","traffic":[{"key":"all","segments":"*","percentage":100000,"variables":{"featureBad":"{"}}],"variablesSchema":{ + "featureBad":{"type":"json","defaultValue":"{"} + }} + },"variables":{ + "globalBad":{"type":"json","defaultValue":"{"}, + "nestedGlobal":{"type":"string","defaultValue":"matched","disabledValue":"miss","conditions":[ + {"variable":"globalBad","operator":"isNull"} + ]}, + "nestedFeature":{"type":"string","defaultValue":"matched","disabledValue":"miss","conditions":[ + {"feature":"sourceFeature","variable":"featureBad","operator":"isNull"} + ]} + }} + """, DatafileContent.class)) + .context(Map.of("userId", "one")) + .modules(List.of(module)) + .logLevel(FeaturevisorLogLevel.DEBUG) + .onDiagnostic(diagnostics::add)); + ChildInstance child = f.spawn(); + + assertNull(f.getVariable("globalBad")); + assertNull(f.getVariable("sourceFeature", "featureBad")); + assertNull(child.getVariable("globalBad")); + assertNull(child.getVariable("sourceFeature", "featureBad")); + assertEquals("matched", f.getVariable("nestedGlobal")); + assertEquals("matched", f.getVariable("nestedFeature")); + + List errors = diagnostics.stream() + .filter(diagnostic -> "evaluation_error".equals(diagnostic.getCode())) + .collect(Collectors.toList()); + String errorDetails = errors.stream() + .map(diagnostic -> diagnostic.getDetails().toString()) + .collect(Collectors.joining(", ")); + assertEquals(3, globalCalls.get()); + assertEquals(3, featureCalls.get()); + for (FeaturevisorDiagnostic diagnostic : errors) { + assertEquals("getVariable failed", diagnostic.getMessage()); + assertNotNull(diagnostic.getOriginalError()); + } + assertEquals(3, errors.stream() + .filter(diagnostic -> "globalBad".equals(diagnostic.getDetails().get("variableKey")) + && !diagnostic.getDetails().containsKey("featureKey")) + .count(), errorDetails); + assertEquals(3, errors.stream() + .filter(diagnostic -> "featureBad".equals(diagnostic.getDetails().get("variableKey")) + && "sourceFeature".equals(diagnostic.getDetails().get("featureKey"))) + .count(), errorDetails); + assertEquals(6, errors.size()); + } + + @Test void malformedStringifiedSegmentsAbortGlobalOverridesAndPrograms() throws Exception { + List diagnostics = new ArrayList<>(); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(mapper.readValue(""" + {"schemaVersion":"2","revision":"segment-errors","segments":{},"features":{},"variables":{ + "ordinary":{"type":"string","defaultValue":"default","overrides":[ + {"segments":"{","value":"wrong"} + ]}, + "program":{"type":"string","defaultValue":"default","overrideProgram":[ + {"key":"bad","keyPath":["bad"],"segments":"{","operations":[ + {"op":"set","path":[],"value":"wrong"} + ],"onMatch":1,"onMiss":1} + ]} + }} + """, DatafileContent.class)) + .logLevel(FeaturevisorLogLevel.DEBUG) + .onDiagnostic(diagnostics::add)); + + assertEquals(Evaluation.REASON_VARIABLE_DEFAULT, f.evaluateVariable("ordinary").getReason()); + assertEquals(Evaluation.REASON_VARIABLE_DEFAULT, f.evaluateVariable("program").getReason()); + assertEquals("default", f.getVariable("ordinary")); + assertEquals("default", f.getVariable("program")); + List errors = diagnostics.stream() + .filter(diagnostic -> "conditions_parse_error".equals(diagnostic.getCode())) + .collect(Collectors.toList()); + assertEquals(2, errors.size()); + assertEquals(java.util.Set.of("ordinary", "program"), errors.stream() + .map(diagnostic -> (String) diagnostic.getDetails().get("entityKey")) + .collect(Collectors.toSet())); + for (FeaturevisorDiagnostic diagnostic : errors) { + assertEquals(java.util.Set.of("conditions", "entityType", "entityKey"), diagnostic.getDetails().keySet()); + assertEquals("variable", diagnostic.getDetails().get("entityType")); + } + } + + @Test void requiredFeatureSourceErrorsUseOrdinaryDisabledSemantics() throws Exception { + FeaturevisorModule module = new FeaturevisorModule("throw-feature-source") + .beforeEvaluation(options -> { + if ("source".equals(options.getFeatureKey())) throw new IllegalStateException("source failed"); + return options; + }); + Featurevisor f = instance(""" + {"schemaVersion":"2","revision":"feature-getter-error","segments":{},"features":{ + "source":{"bucketBy":"userId","force":[{"conditions":"*","enabled":true}]} + },"variables":{ + "disabledMatch":{"type":"string","defaultValue":"matched","disabledValue":"miss","requiredFeatures":[ + {"feature":"source","enabled":false} + ]}, + "notMatch":{"type":"string","defaultValue":"matched","disabledValue":"miss","requiredFeatures":[ + {"not":["source"]} + ]} + }} + """, module); + + assertEquals("matched", f.getVariable("disabledMatch")); + assertEquals("matched", f.getVariable("notMatch")); + } + + @Test void requirementPathsFollowJavaScriptArrayOwnProperties() throws Exception { + Featurevisor f = instance(""" + {"schemaVersion":"2","revision":"array-paths","segments":{},"features":{},"variables":{ + "array":{"type":"array","defaultValue":["first"]}, + "object":{"type":"object","defaultValue":{"0":"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,"disabledValue":false,"conditions":[ + {"variable":"object","path":[0],"operator":"equals","value":"first"} + ]} + }} + """); + + assertEquals(Boolean.TRUE, f.getVariable("stringIndex")); + assertEquals(Boolean.TRUE, f.getVariable("numericIndex")); + assertEquals(Boolean.TRUE, f.getVariable("length")); + assertEquals(Boolean.FALSE, f.getVariable("numericObject")); + } + + @Test void falseRuleAndVariationSelectorsSkipPrerequisites() throws Exception { + AtomicInteger sourceCalls = new AtomicInteger(); + FeaturevisorModule module = new FeaturevisorModule("order") + .beforeEvaluation(options -> { + if ("source".equals(options.getVariableKey())) sourceCalls.incrementAndGet(); + return options; + }); + Featurevisor f = instance(""" + {"schemaVersion":"2","revision":"order","segments":{},"features":{"subject":{"bucketBy":"userId","variablesSchema":{"ruleValue":{"type":"string","defaultValue":"default"},"variationValue":{"type":"string","defaultValue":"default"}},"variations":[{"value":"control","variableOverrides":{"variationValue":[{"conditions":[{"attribute":"country","operator":"equals","value":"nl"},{"variable":"source","operator":"equals","value":true}],"value":"wrong"}]}}],"traffic":[{"key":"all","segments":"*","percentage":100000,"variation":"control","variableOverrides":{"ruleValue":[{"conditions":[{"attribute":"country","operator":"equals","value":"nl"},{"variable":"source","operator":"equals","value":true}],"value":"wrong"}]}}]}},"variables":{"source":{"type":"boolean","defaultValue":true}}} + """, module); + Map context = Map.of("country", "de"); + + assertEquals("default", f.getVariableString("subject", "ruleValue", context, null)); + assertEquals("default", f.getVariableString("subject", "variationValue", context, null)); + assertEquals(0, sourceCalls.get()); + } + + @Test void publicCompatibilityAliasesAreAbsent() throws Exception { + assertThrows(NoSuchMethodException.class, () -> Feature.class.getMethod("getRequired")); + assertThrows(NoSuchMethodException.class, () -> Evaluation.class.getMethod("getSticky")); + assertThrows(NoSuchMethodException.class, () -> FeaturevisorModule.class.getMethod("before", java.util.function.Function.class)); + assertThrows(ClassNotFoundException.class, () -> Class.forName("com.featurevisor.sdk.RequiredWithVariation")); + assertThrows(ClassNotFoundException.class, () -> Class.forName("com.featurevisor.sdk.VariableKey")); + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/WarmEvaluationContractTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/WarmEvaluationContractTest.java new file mode 100644 index 0000000..aa7d3d9 --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/WarmEvaluationContractTest.java @@ -0,0 +1,204 @@ +package com.featurevisor.sdk; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; + +public class WarmEvaluationContractTest { + @Test + @SuppressWarnings("unchecked") + public void testDiagnosticMutationCannotEscapeIntoCachedExpressionsWithIdenticalSource() throws Exception { + String source = """ + {"attribute":"country","operator":"matches","value":"[", + "metadata":{"notes":[{"text":"original"}]}} + """; + DatafileContent datafile = DatafileContent.fromJson(""" + {"schemaVersion":"2","revision":"diagnostic-ownership","features":{}, + "segments":{"public":{"conditions":"*"}},"variables":{ + "first":{"type":"string","defaultValue":"default","overrides":[ + {"conditions":"*","value":"wrong"}]}, + "second":{"type":"string","defaultValue":"default","overrides":[ + {"conditions":"*","value":"wrong"}]} + }} + """); + datafile.getSegments().get("public").setConditions(source); + datafile.getVariables().values().forEach(variable -> + variable.getOverrides().get(0).setConditions(source)); + List diagnostics = new ArrayList<>(); + List> observed = new ArrayList<>(); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(datafile) + .logLevel(FeaturevisorLogLevel.WARN) + .onDiagnostic(diagnostic -> { + if (!"condition_match_error".equals(diagnostic.getCode())) { + return; + } + diagnostics.add(diagnostic); + Map condition = (Map) diagnostic.getDetails().get("condition"); + Map metadata = (Map) condition.get("metadata"); + List notes = (List) metadata.get("notes"); + Map note = (Map) notes.get(0); + observed.add(List.of(condition.get("operator"), condition.get("value"), note.get("text"))); + condition.put("operator", "equals"); + condition.put("value", "nl"); + note.put("text", "changed"); + notes.add("changed"); + })); + Map context = Map.of("country", "nl"); + + assertEquals("default", sdk.getVariable("first", context)); + Map publicCondition = (Map) sdk.getSegment("public").getConditions(); + publicCondition.put("operator", "equals"); + publicCondition.put("value", "nl"); + assertEquals("default", sdk.getVariable("second", context)); + assertEquals("default", sdk.getVariable("first", context)); + assertEquals("default", sdk.getVariable("second", context)); + + assertEquals(4, diagnostics.size()); + for (int index = 0; index < diagnostics.size(); index++) { + FeaturevisorDiagnostic diagnostic = diagnostics.get(index); + assertEquals(List.of("matches", "[", "original"), observed.get(index)); + assertEquals(FeaturevisorLogLevel.WARN, diagnostic.getLevel()); + assertTrue(diagnostic.getOriginalError() instanceof java.util.regex.PatternSyntaxException); + assertEquals(((Exception) diagnostic.getOriginalError()).getMessage(), diagnostic.getMessage()); + assertEquals(context, diagnostic.getDetails().get("context")); + assertNotSame(publicCondition, diagnostic.getDetails().get("condition")); + if (index > 0) { + assertNotSame(diagnostics.get(index - 1).getDetails().get("condition"), + diagnostic.getDetails().get("condition")); + } + } + sdk.close(); + } + + private static DatafileContent datafile() throws Exception { + return DatafileContent.fromJson(""" + { + "schemaVersion": "2", + "revision": "warm-contract", + "segments": {}, + "features": { + "enabled": { + "bucketBy": "userId", + "traffic": [{"key":"all","segments":"*","percentage":100000,"enabled":true}] + } + } + } + """); + } + + private static DatafileContent conditionalDatafile(String revision, String country, String value) + throws Exception { + return DatafileContent.fromJson(String.format(""" + { + "schemaVersion":"2", + "revision":"%s", + "segments":{}, + "features":{}, + "variables":{ + "message":{ + "type":"string", + "defaultValue":"default", + "overrides":[{ + "conditions":"{\\"attribute\\":\\"country\\",\\"operator\\":\\"equals\\",\\"value\\":\\"%s\\"}", + "value":"%s" + }] + } + } + } + """, revision, country, value)); + } + + @Test + public void testDiagnosticSubscriptionAddedByCallbackSeesCurrentEvaluation() throws Exception { + List received = new ArrayList<>(); + AtomicBoolean subscribed = new AtomicBoolean(); + final FeaturevisorModuleApi[] api = {null}; + FeaturevisorModule module = new FeaturevisorModule("observer") + .setup(value -> api[0] = value) + .beforeEvaluation(options -> { + if (subscribed.compareAndSet(false, true)) { + api[0].onDiagnostic( + received::add, + new FeaturevisorModuleDiagnosticOptions(FeaturevisorLogLevel.DEBUG) + ); + } + return options; + }); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(datafile()) + .logLevel(FeaturevisorLogLevel.FATAL) + .modules(List.of(module))); + + sdk.isEnabled("enabled", Map.of("userId", "1")); + + FeaturevisorDiagnostic diagnostic = received.stream() + .filter(item -> "override from rule".equals(item.getCode())) + .findFirst() + .orElseThrow(); + assertEquals(FeaturevisorLogLevel.DEBUG, diagnostic.getLevel()); + assertEquals("override from rule", diagnostic.getMessage()); + assertEquals("enabled", diagnostic.getDetails().get("featureKey")); + assertTrue(diagnostic.getDetails().containsKey("bucketKey")); + assertTrue(diagnostic.getDetails().containsKey("bucketValue")); + sdk.close(); + } + + @Test + public void testThresholdChangedByCallbackAffectsCurrentEvaluation() throws Exception { + List received = new ArrayList<>(); + final Featurevisor[] sdk = {null}; + FeaturevisorModule module = new FeaturevisorModule("threshold") + .beforeEvaluation(options -> { + sdk[0].setLogLevel(FeaturevisorLogLevel.DEBUG); + return options; + }); + sdk[0] = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(datafile()) + .logLevel(FeaturevisorLogLevel.FATAL) + .onDiagnostic(received::add) + .modules(List.of(module))); + received.clear(); + + sdk[0].isEnabled("enabled", Map.of("userId", "1")); + + FeaturevisorDiagnostic diagnostic = received.stream() + .filter(item -> "override from rule".equals(item.getCode())) + .findFirst() + .orElseThrow(); + assertEquals(FeaturevisorLogLevel.DEBUG, diagnostic.getLevel()); + assertEquals("enabled", diagnostic.getDetails().get("featureKey")); + sdk[0].close(); + } + + @Test + public void testReentrantDatafileReplacementKeepsCurrentGenerationAlive() throws Exception { + AtomicBoolean replaced = new AtomicBoolean(); + final Featurevisor[] sdk = {null}; + FeaturevisorModule module = new FeaturevisorModule("replacement") + .beforeEvaluation(options -> { + if (replaced.compareAndSet(false, true)) { + try { + sdk[0].setDatafile(conditionalDatafile("new", "de", "germany"), true); + } catch (Exception error) { + throw new RuntimeException(error); + } + } + return options; + }); + sdk[0] = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(conditionalDatafile("old", "nl", "netherlands")) + .logLevel(FeaturevisorLogLevel.FATAL) + .modules(List.of(module))); + + assertEquals("netherlands", sdk[0].getVariable("message", Map.of("country", "nl"))); + assertEquals("germany", sdk[0].getVariable("message", Map.of("country", "de"))); + sdk[0].close(); + } +} diff --git a/pom.xml b/pom.xml index 5b8dc94..e109755 100644 --- a/pom.xml +++ b/pom.xml @@ -19,17 +19,19 @@ - 4.1.0 + 5.0.0 11 11 15 15 UTF-8 - 2.15.2 + 2.22.3 + + 2.22 5.9.2 4.7.5 1.3 - 1.20.2 + 1.23.0 3.11.0 3.0.0 3.3.0 @@ -99,7 +101,7 @@ com.fasterxml.jackson.core jackson-annotations - ${jackson.version} + ${jackson-annotations.version} org.junit.jupiter diff --git a/scripts/ReadmeInitializationCheck.java b/scripts/ReadmeInitializationCheck.java new file mode 100644 index 0000000..d460b0a --- /dev/null +++ b/scripts/ReadmeInitializationCheck.java @@ -0,0 +1,63 @@ +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import java.util.concurrent.TimeUnit; +import javax.tools.ToolProvider; + +public class ReadmeInitializationCheck { + public static void main(String[] args) throws Exception { + String classpath = args[0]; + Matcher fence = Pattern.compile("## Initialization\\n.*?```java\\n(.*?)\\n```", Pattern.DOTALL) + .matcher(Files.readString(Path.of("README.md"))); + if (!fence.find()) throw new AssertionError("README initialization fence not found"); + Path temporary = Files.createTempDirectory("featurevisor-readme-"); + try { + Path source = temporary.resolve("Main.java"); + Files.writeString(source, fence.group(1)); + int result = ToolProvider.getSystemJavaCompiler().run(null, null, null, + "--release", "11", "-encoding", "UTF-8", "-cp", classpath, + "-d", temporary.toString(), source.toString()); + if (result != 0) throw new AssertionError("README compilation failed"); + Path datafile = temporary.resolve("featurevisor-all.json"); + Files.writeString(datafile, "{\"schemaVersion\":\"2\",\"revision\":\"readme\"," + + "\"segments\":{},\"variables\":{},\"features\":{\"checkout\":{" + + "\"hash\":\"checkout\",\"bucketBy\":\"userId\",\"traffic\":[{" + + "\"key\":\"all\",\"segments\":\"*\",\"percentage\":100000}]}}}"); + execute(temporary, classpath, false, "true"); + Files.writeString(datafile, "{"); + execute(temporary, classpath, true, "Exception"); + Files.delete(datafile); + execute(temporary, classpath, true, "NoSuchFileException"); + System.out.println("README initialization passed: Java 11 compilation, evaluation, invalid JSON, missing file (runtime " + + System.getProperty("java.specification.version") + ")"); + } finally { + try (Stream paths = Files.walk(temporary)) { + for (Path path : (Iterable) paths.sorted(Comparator.reverseOrder())::iterator) { + Files.delete(path); + } + } + } + } + + private static void execute(Path directory, String classpath, boolean failure, String expected) + throws Exception { + String java = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + Path output = directory.resolve("output.txt"); + Process process = new ProcessBuilder(java, "-cp", + directory + File.pathSeparator + classpath, "Main") + .directory(directory.toFile()).redirectErrorStream(true).redirectOutput(output.toFile()).start(); + if (!process.waitFor(30, TimeUnit.SECONDS)) { + process.destroyForcibly().waitFor(); + throw new AssertionError("README execution timed out"); + } + String text = Files.readString(output, StandardCharsets.UTF_8); + if ((process.exitValue() != 0) != failure || !text.contains(expected)) { + throw new AssertionError("Unexpected README result: " + text); + } + } +} diff --git a/scripts/fixtures/MinimumRuntimeConsumer.java b/scripts/fixtures/MinimumRuntimeConsumer.java new file mode 100644 index 0000000..7eeface --- /dev/null +++ b/scripts/fixtures/MinimumRuntimeConsumer.java @@ -0,0 +1,77 @@ +import com.featurevisor.sdk.Featurevisor; +import com.featurevisor.sdk.FeaturevisorLogLevel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** A standalone consumer compiled against the packaged SDK, without JUnit. */ +public final class MinimumRuntimeConsumer { + private static int checks; + + private static void equal(Object expected, Object actual) { + checks++; + if (!Objects.equals(expected, actual)) { + throw new AssertionError("Expected " + expected + ", found " + actual); + } + } + + public static void main(String[] args) throws Exception { + equal(args[1], System.getProperty("java.specification.version")); + equal(true, Featurevisor.class.getProtectionDomain().getCodeSource() + .getLocation().getPath().endsWith(".jar")); + for (String name : List.of("dev.openfeature.sdk.OpenFeatureAPI", + "com.featurevisor.openfeature.FeaturevisorOpenFeatureProvider", + "org.junit.jupiter.api.Test")) { + try { + Class.forName(name); + throw new AssertionError("Unexpected consumer dependency: " + name); + } catch (ClassNotFoundException expected) { + checks++; + } + } + + String datafile = Files.readString(Path.of(args[0]), StandardCharsets.UTF_8); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafileString(datafile) + .context(Map.of("userId", "123", "country", "nl")) + .logLevel(FeaturevisorLogLevel.FATAL)); + try { + equal(true, sdk.isEnabled("base")); + equal(true, sdk.isEnabled("policy")); + equal(false, sdk.isEnabled("missing")); + equal(true, sdk.getVariable("gate")); + equal(Map.of("grants", List.of("feature")), sdk.getVariable("policy", "grants")); + Map grants = Map.of("grants", List.of("read"), "label", "café"); + equal(grants, sdk.getVariable("policy")); + equal(grants, sdk.getVariable("policy")); + equal(grants, sdk.getVariable("policyAlias")); + equal(Map.of("enabled", true, "policy", grants), sdk.getVariable("screen")); + equal("variable_composed", sdk.evaluateVariable("policyAlias").getReason()); + equal(Map.of("grants", List.of()), sdk.getVariable("policy", Map.of("country", "de"))); + sdk.setContext(Map.of("country", "de")); + equal(Map.of("grants", List.of()), sdk.getVariable("policy")); + sdk.setContext(Map.of("country", "nl")); + equal(grants, sdk.getVariable("policy")); + sdk.setStickyVariables(Map.of("gate", false)); + equal(false, sdk.isEnabled("policy")); + equal(null, sdk.getVariable("policy", "grants")); + equal(Map.of("grants", List.of("blocked")), sdk.getVariable("policy")); + sdk.setStickyVariables(Map.of(), true); + equal(true, sdk.isEnabled("policy")); + equal(grants, sdk.getVariable("policy")); + sdk.setStickyFeatures(Map.of("base", Map.of("enabled", false))); + equal(false, sdk.isEnabled("policy")); + sdk.setStickyFeatures(Map.of(), true); + equal(true, sdk.isEnabled("policy")); + sdk.setDatafile(datafile.replace("\"read\"", "\"write\""), true); + equal(Map.of("grants", List.of("write"), "label", "café"), sdk.getVariable("policy")); + } finally { + sdk.close(); + } + System.out.println("Packaged SDK consumer passed " + checks + " checks on Java " + + System.getProperty("java.version") + " (consumer compiled with --release 11)"); + } +} diff --git a/scripts/fixtures/minimum-runtime-datafile.json b/scripts/fixtures/minimum-runtime-datafile.json new file mode 100644 index 0000000..c4dd177 --- /dev/null +++ b/scripts/fixtures/minimum-runtime-datafile.json @@ -0,0 +1,57 @@ +{ + "schemaVersion": "4", + "revision": "minimum-runtime", + "segments": {}, + "features": { + "base": { + "hash": "base", + "bucketBy": "userId", + "traffic": [{ "key": "all", "conditions": "*", "percentage": 100000 }] + }, + "policy": { + "hash": "policy", + "bucketBy": "userId", + "conditions": [ + { "feature": "base" }, + { "variable": "gate", "operator": "equals", "value": true } + ], + "traffic": [{ "key": "all", "conditions": "*", "percentage": 100000 }], + "variablesSchema": { + "grants": { "type": "json", "defaultValue": { "grants": ["feature"] } } + } + } + }, + "variables": { + "gate": { "type": "boolean", "defaultValue": true }, + "policyAlias": { "type": "json", "compose": "policy" }, + "screen": { "type": "object", "compose": [ + { "key": "enabled", "feature": "policy" }, + { "key": "policy", "variable": "policyAlias" } + ] }, + "policy": { + "type": "json", + "defaultValue": { "grants": [] }, + "disabledValue": { "grants": ["blocked"] }, + "conditions": [ + { "variable": "gate", "operator": "equals", "value": true } + ], + "overrideProgram": [ + { + "key": "grant", + "keyPath": ["grant"], + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + "operations": [ + { "op": "append", "path": ["grants"], "value": "read" }, + { "op": "set", "path": ["label"], "value": "café" } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } +} diff --git a/scripts/test-readme-initialization.sh b/scripts/test-readme-initialization.sh new file mode 100644 index 0000000..ee232fa --- /dev/null +++ b/scripts/test-readme-initialization.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$root" +temporary="$(mktemp -d "${TMPDIR:-/tmp}/featurevisor-readme-classpath.XXXXXX")" +trap 'rm -rf "$temporary"' EXIT + +version="$(sed -n 's/.*\([^<]*\)<\/revision>.*/\1/p' pom.xml)" +sdk_jar="$root/featurevisor-sdk/target/featurevisor-java-$version.jar" +if [[ ! -f "$sdk_jar" ]]; then + echo "Package the SDK first: mvn -Dmaven.test.skip=true package" >&2 + exit 1 +fi +mvn --batch-mode -q -pl featurevisor-sdk \ + org.apache.maven.plugins:maven-dependency-plugin:3.7.0:build-classpath \ + -DincludeScope=runtime -Dmdep.outputFile="$temporary/classpath" "$@" +classpath="$sdk_jar:$(<"$temporary/classpath")" +java="${JAVA_HOME:+$JAVA_HOME/bin/}java" +"$java" scripts/ReadmeInitializationCheck.java "$classpath" diff --git a/scripts/test-release.sh b/scripts/test-release.sh new file mode 100644 index 0000000..66e8f44 --- /dev/null +++ b/scripts/test-release.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +version="${1:?Pass the prepared version}" +root="$(pwd)" +major="${version%%.*}" +bash scripts/verify-release.sh "v$version" +for tag in "v$((major - 1)).0.0" "v$((major + 1)).0.0" "v$major.99.0" "v$version-beta.1" "$version" "invalid"; do + if bash scripts/verify-release.sh "$tag" >/dev/null 2>&1; then + echo "Unexpected accepted tag: $tag" >&2 + exit 1 + fi +done + +directory="$(mktemp -d)" +trap 'rm -rf "$directory"' EXIT +cp README.md "$directory/" +if [[ -f pom.xml ]]; then + cp pom.xml "$directory/" + mkdir -p "$directory/featurevisor-sdk/src/main/java/com/featurevisor/cli" + cp featurevisor-sdk/src/main/java/com/featurevisor/cli/CLI.java "$directory/featurevisor-sdk/src/main/java/com/featurevisor/cli/" +fi +# Both installation examples must agree, even when the other is correct. +for occurrence in 1 2; do + awk -v version="$version" -v occurrence="$occurrence" ' + index($0, version) && (/from: / || //) { + count++ + if (count == occurrence) sub(version, "0.0.0") + } + { print } + ' README.md > "$directory/README.md" + if (cd "$directory" && bash "$root/scripts/verify-release.sh" "v$version") >/dev/null 2>&1; then + echo "Accepted a mismatched README installation version" >&2 + exit 1 + fi +done +checks=9 +if [[ -f pom.xml ]]; then + cp README.md "$directory/" + for file in pom.xml featurevisor-sdk/src/main/java/com/featurevisor/cli/CLI.java; do + sed "s/$version/0.0.0/g" "$file" > "$directory/$file" + if (cd "$directory" && bash "$root/scripts/verify-release.sh" "v$version") >/dev/null 2>&1; then + echo "Accepted a mismatched version in $file" >&2 + exit 1 + fi + cp "$file" "$directory/$file" + checks=$((checks + 1)) + done +fi +echo "$checks release validation checks passed." diff --git a/scripts/test-runtime-commands.mjs b/scripts/test-runtime-commands.mjs new file mode 100644 index 0000000..ef604ec --- /dev/null +++ b/scripts/test-runtime-commands.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const [monorepo, executable, ...nativeArgs] = process.argv.slice(2); +assert(monorepo && executable, "Pass the built monorepo directory, native executable and any launcher arguments"); +const cli = path.resolve(monorepo, "packages/cli/bin.js"); +assert(fs.existsSync(cli), "Expected the local Node CLI"); +const temp = fs.mkdtempSync(path.join(os.tmpdir(), "featurevisor runtime ")); +let checks = 0; +const failures = []; +function write(file, content) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} +function project(name, environments, sets) { + const root = path.join(temp, name); + write(path.join(root, "featurevisor.config.js"), `module.exports = ${JSON.stringify({ + ...(environments ? { environments: ["production"] } : {}), + ...(sets ? { sets: true, setsDirectoryPath: path.join(root, "lanes with spaces") } : {}), + })};\n`); + for (const set of sets ? ["store front", "admin"] : [""]) { + const directory = set ? path.join(root, "lanes with spaces", set) : root; + const rules = '- key: everyone\n conditions: "*"\n percentage: 100\n'; + write(path.join(directory, "features/checkout.yml"), + "description: Checkout\nbucketBy: userId\nrules:\n" + + (environments ? " production:\n" + rules.split("\n").filter(Boolean).map(line => " " + line).join("\n") + "\n" + : rules.split("\n").filter(Boolean).map(line => " " + line).join("\n") + "\n")); + write(path.join(directory, "variables/colour.yml"), + `description: Colour\ntype: string\ndefaultValue: ${set === "admin" ? "red" : "blue"}\n`); + write(path.join(directory, "targets/all.yml"), "description: All definitions\n"); + write(path.join(directory, "targets/nested/web.yml"), "description: Nested target\nincludeFeatures:\n - checkout\nincludeVariables:\n - colour\n"); + write(path.join(directory, "targets/selected web.yml"), "description: Selected checkout\nincludeFeatures:\n - checkout\nincludeVariables:\n - colour\n"); + } + return root; +} +function snapshot(directory) { + return fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).flatMap(entry => { + const file = path.join(directory, entry.name); + return entry.isDirectory() ? snapshot(file) : [[file, fs.readFileSync(file, "utf8")]]; + }); +} +function run(label, root, command, options, expected, pattern) { + const args = [...nativeArgs, command, "--projectDirectoryPath=" + root, "--n=2", ...options]; + const result = spawnSync(executable, args, { + encoding: "utf8", timeout: 60000, + env: { ...process.env, PATH: path.join(temp, "bin") + path.delimiter + process.env.PATH }, + }); + const output = result.stdout + result.stderr; + try { + assert.equal(result.error, undefined); + assert.equal(result.signal, null); + assert.equal(result.status === 0, expected === 0, output); + if (pattern) assert.match(output, pattern); + checks++; + } catch (error) { failures.push(label + ": " + error.message); } +} +try { + // Only delegation is redirected. This executes the real local Node CLI without npx downloads. + write(path.join(temp, "bin/npx"), `#!/usr/bin/env node\nif (process.argv[2] !== "featurevisor") process.exit(97);\nprocess.argv = [process.execPath, ${JSON.stringify(cli)}, ...process.argv.slice(3)];\nrequire(${JSON.stringify(cli)});\n`); + fs.chmodSync(path.join(temp, "bin/npx"), 0o755); + const flat = project("flat", false, false); + const configured = project("configured", true, false); + const sets = project("sets", false, true); + const configuredSets = project("configured sets", true, true); + const emptySets = project("empty sets", false, true); + fs.rmSync(path.join(emptySets, "lanes with spaces"), { recursive: true }); + write(path.join(emptySets, "lanes with spaces/ignored.txt"), "not a set"); + const before = snapshot(temp); + for (const command of ["benchmark", "assess-distribution"]) { + const options = ["--feature=checkout"]; + run(command + " inflate one", flat, command, [...options, "--inflate=1"], 0); + for (const value of ["0", "-1", "1.5", "invalid"]) { + run(command + " invalid inflate " + value, flat, command, [...options, "--inflate=" + value], 1); + } + run(command + " flat", flat, command, options, 0, command === "benchmark" ? /Evaluated value\s*:\s*true/ : /(?:enabled|true): 2/); + run(command + " configured", configured, command, [...options, "--environment=production"], 0); + run(command + " missing environment", configured, command, options, 1); + run(command + " invalid environment", configured, command, [...options, "--environment=unknown"], 1); + run(command + " unexpected environment", flat, command, [...options, "--environment=production"], 1); + for (const [root, env] of [[sets, []], [configuredSets, ["--environment=production"]]]) { + run(command + " selected set", root, command, [...options, ...env, "--set=store front", "--target=selected web"], 0); + run(command + " all sets", root, command, [...options, ...env], 0, /Set: admin[\s\S]*Set: store front/); + run(command + " all sets with nested targets", root, command, + [...options, ...env, "--target=nested.web", "--target=selected web", "--target=nested.web"], 0, + /Set: admin[\s\S]*Target: nested\.web[\s\S]*Target: selected web[\s\S]*Set: store front[\s\S]*Target: nested\.web[\s\S]*Target: selected web/); + run(command + " selected set with nested targets", root, command, + [...options, ...env, "--set=store front", "--target=nested.web", "--target=selected web"], 0, + /Set: store front[\s\S]*Target: nested\.web[\s\S]*Target: selected web/); + run(command + " unknown set", root, command, [...options, ...env, "--set=unknown"], 1); + } + run(command + " targets", flat, command, [...options, "--target=all", "--target=selected web", "--target=all"], 0, /Target: selected web/); + run(command + " unknown target", flat, command, [...options, "--target=all", "--target=unknown"], 1); + run(command + " empty sets", emptySets, command, options, 1); + run(command + " all sets missing environment", configuredSets, command, options, 1); + run(command + " all sets invalid environment", configuredSets, command, [...options, "--environment=unknown"], 1); + run(command + " all sets unknown target", sets, command, [...options, "--target=unknown"], 1); + } + run("global variable flat", flat, "benchmark", ["--variable=colour"], 0, /Evaluated value\s*:\s*"?blue/); + run("global variable selected set", sets, "benchmark", ["--variable=colour", "--set=admin"], 0, /Evaluated value\s*:\s*"?red/); + run("global variable all sets", sets, "benchmark", ["--variable=colour"], 0, + /Set: admin[\s\S]*Evaluated value\s*:\s*"?red[\s\S]*Set: store front[\s\S]*Evaluated value\s*:\s*"?blue/); + assert.deepEqual(snapshot(temp), before, "Runtime commands must not write datafiles or allocation state"); + console.log(`${checks} runtime integration checks passed; ${failures.length} failed. Project files unchanged.`); + for (const failure of failures) console.error(failure); + if (failures.length) process.exitCode = 1; +} finally { + fs.rmSync(temp, { recursive: true, force: true }); +} diff --git a/scripts/test-runtime-minimum.sh b/scripts/test-runtime-minimum.sh new file mode 100644 index 0000000..6ed7b8a --- /dev/null +++ b/scripts/test-runtime-minimum.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$root" + +# The default verifies the supported minimum. CI also runs the same consumer on +# its latest JDK, supplying that version explicitly rather than claiming Java 11. +expected_java="${1:-11}" +if [[ -n "${JAVA_HOME:-}" ]]; then + java="$JAVA_HOME/bin/java" + javac="$JAVA_HOME/bin/javac" +else + java="java" + javac="javac" +fi +actual_java="$("$java" -XshowSettings:properties -version 2>&1 | sed -n 's/^[[:space:]]*java.specification.version = //p')" +if [[ "$actual_java" != "$expected_java" ]]; then + echo "Expected Java $expected_java execution runtime, found $actual_java. Set JAVA_HOME to the intended JDK." >&2 + exit 1 +fi + +version="$(sed -n 's/.*\([^<]*\)<\/revision>.*/\1/p' pom.xml)" +sdk_jar="$root/featurevisor-sdk/target/featurevisor-java-$version.jar" +if [[ -z "$version" || ! -f "$sdk_jar" ]]; then + echo "Package the Maven reactor before running this consumer: mvn -Dmaven.test.skip=true package" >&2 + exit 1 +fi + +temporary="$(mktemp -d "${TMPDIR:-/tmp}/featurevisor-java-consumer.XXXXXX")" +trap 'rm -rf "$temporary"' EXIT + +# Resolve only SDK runtime dependencies, never reactor classes, JUnit or the +# separate OpenFeature provider. This goal does not rebuild shared output. +mvn --batch-mode -q -pl featurevisor-sdk \ + org.apache.maven.plugins:maven-dependency-plugin:3.7.0:build-classpath \ + -DincludeScope=runtime -Dmdep.outputFile="$temporary/classpath" +classpath="$sdk_jar:$(<"$temporary/classpath")" +"$javac" --release 11 -encoding UTF-8 -cp "$classpath" -d "$temporary" \ + scripts/fixtures/MinimumRuntimeConsumer.java +"$java" -cp "$temporary:$classpath" MinimumRuntimeConsumer \ + scripts/fixtures/minimum-runtime-datafile.json "$expected_java" diff --git a/scripts/verify-artifacts.sh b/scripts/verify-artifacts.sh index f6b4098..bab3ce8 100644 --- a/scripts/verify-artifacts.sh +++ b/scripts/verify-artifacts.sh @@ -2,17 +2,11 @@ set -euo pipefail -find_main_jar() { - find "$1/target" -maxdepth 1 -type f -name "$2-*.jar" \ - ! -name "*-sources.jar" \ - ! -name "*-javadoc.jar" \ - -print -quit -} +version="$(sed -n 's/.*\([^<]*\)<\/revision>.*/\1/p' pom.xml)" +sdk_jar="featurevisor-sdk/target/featurevisor-java-$version.jar" +provider_jar="featurevisor-openfeature/target/featurevisor-openfeature-$version.jar" -sdk_jar="$(find_main_jar featurevisor-sdk featurevisor-java)" -provider_jar="$(find_main_jar featurevisor-openfeature featurevisor-openfeature)" - -if [[ -z "$sdk_jar" || -z "$provider_jar" ]]; then +if [[ -z "$version" || ! -f "$sdk_jar" || ! -f "$provider_jar" ]]; then echo "Expected SDK and provider JARs were not found" >&2 exit 1 fi @@ -42,12 +36,24 @@ if ! grep -q 'dev\.openfeature' featurevisor-openfeature/.fla exit 1 fi +for pom in .flattened-pom.xml featurevisor-sdk/.flattened-pom.xml featurevisor-openfeature/.flattened-pom.xml; do + if ! grep -Fq "$version" "$pom"; then + echo "Published POM does not match revision $version: $pom" >&2 + exit 1 + fi +done + +if ! grep -A1 'featurevisor-java' featurevisor-openfeature/.flattened-pom.xml | grep -Fq "$version"; then + echo "Provider dependency does not match SDK revision $version" >&2 + exit 1 +fi + for artifact in \ - featurevisor-sdk/target/featurevisor-java-*-sources.jar \ - featurevisor-sdk/target/featurevisor-java-*-javadoc.jar \ - featurevisor-openfeature/target/featurevisor-openfeature-*-sources.jar \ - featurevisor-openfeature/target/featurevisor-openfeature-*-javadoc.jar; do - if ! compgen -G "$artifact" >/dev/null; then + "featurevisor-sdk/target/featurevisor-java-$version-sources.jar" \ + "featurevisor-sdk/target/featurevisor-java-$version-javadoc.jar" \ + "featurevisor-openfeature/target/featurevisor-openfeature-$version-sources.jar" \ + "featurevisor-openfeature/target/featurevisor-openfeature-$version-javadoc.jar"; do + if [[ ! -f "$artifact" ]]; then echo "Missing published artifact: $artifact" >&2 exit 1 fi diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh new file mode 100644 index 0000000..7b8267d --- /dev/null +++ b/scripts/verify-release.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +tag="${1:?Pass a release tag}" +if [[ ! "$tag" =~ ^v5\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Expected a Featurevisor Java v5 semantic version tag" >&2 + exit 1 +fi +version="${tag#v}" +if ! grep -Fq "$version" pom.xml; then + echo "Tag $tag does not match the Maven revision" >&2 + exit 1 +fi +if [[ "$(grep -Fc "$version" README.md)" != 2 ]]; then + echo "SDK and provider README installation versions must match $tag" >&2 + exit 1 +fi +if ! grep -Fq "version = \"$version\"" featurevisor-sdk/src/main/java/com/featurevisor/cli/CLI.java; then + echo "CLI version does not match $tag" >&2 + exit 1 +fi +echo "Validated Featurevisor Java $version"