From 0bbf38bc6508c40d0a7ac4938c88bbc224878e47 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 30 Aug 2026 22:27:37 +0200 Subject: [PATCH] feat: variables testing in Java --- README.md | 6 +- .../main/java/com/featurevisor/cli/CLI.java | 175 ++++++++++++------ .../com/featurevisor/cli/CLIOptionsTest.java | 4 +- pom.xml | 2 +- 4 files changed, 128 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index ae7a9fa..a12058b 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ Add the Featurevisor Java SDK as a dependency with your desired version: com.featurevisor featurevisor-java - 4.0.0 + 4.1.0 ``` @@ -901,7 +901,7 @@ Add the provider with the same version as the Featurevisor Java SDK: com.featurevisor featurevisor-openfeature - 4.0.0 + 4.1.0 ``` @@ -973,7 +973,7 @@ $ make verify-artifacts ### Releasing 1. Merge the release changes into `main`. -2. Tag the release with a `v` prefix, such as `v4.0.0`, and push the tag. +2. Tag the release with a `v` prefix, such as `v4.1.0`, 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/featurevisor-sdk/src/main/java/com/featurevisor/cli/CLI.java b/featurevisor-sdk/src/main/java/com/featurevisor/cli/CLI.java index 73b594a..6209184 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/cli/CLI.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/cli/CLI.java @@ -34,7 +34,7 @@ @Command( name = "featurevisor", mixinStandardHelpOptions = true, - version = "4.0.0", + version = "4.1.0", description = "Featurevisor Java Library CLI - Test runner, benchmark, and distribution assessment" ) public class CLI implements Runnable { @@ -209,14 +209,13 @@ String selectDatafileKeyForAssertion(Map assertion, Map assertion, String featureKey, @SuppressWarnings("unchecked") Map childContext = (Map) child.getOrDefault("context", new HashMap<>()); com.featurevisor.sdk.ChildInstance childF = spawn(f, childContext); - TestResult childResult = testFeature(child, featureKey, childF, level); + TestResult childResult; + try { + childResult = testFeature(child, featureKey, childF, level); + } finally { + childF.close(); + } duration += childResult.duration; hasError = hasError || childResult.hasError; @@ -538,19 +542,17 @@ private TestResult testFeature(Map assertion, String featureKey, private TestResult testGlobalVariable(Map assertion, String variableKey, Featurevisor f) { @SuppressWarnings("unchecked") Map context = (Map) assertion.getOrDefault("context", new HashMap<>()); - @SuppressWarnings("unchecked") Map stickyVariables = (Map) assertion.getOrDefault("stickyVariables", new HashMap<>()); f.setContext(context, true); - f.setStickyVariables(stickyVariables, true); Featurevisor.OverrideOptions options = new Featurevisor.OverrideOptions(); if (assertion.containsKey("defaultVariableValue")) options.setDefaultVariableValue(assertion.get("defaultVariableValue")); long startTime = System.nanoTime(); - Evaluation evaluation = f.evaluateVariable(variableKey, context, options); + Evaluation evaluation = f.evaluateVariable(variableKey, new HashMap<>(), options); boolean hasError = false; StringBuilder errors = new StringBuilder(); if (assertion.containsKey("expectedValue") && !Objects.equals(assertion.get("expectedValue"), evaluation.getVariableValue())) { hasError = true; - errors.append(" ✘ expectedValue: expected ").append(assertion.get("expectedValue")) - .append(" but received ").append(evaluation.getVariableValue()).append("\n"); + errors.append(" ✘ expectedValue: expected ").append(formatTestValue(assertion.get("expectedValue"))) + .append(" but received ").append(formatTestValue(evaluation.getVariableValue())).append("\n"); } if (assertion.containsKey("expectedEvaluation")) { @SuppressWarnings("unchecked") Map expected = (Map) assertion.get("expectedEvaluation"); @@ -559,13 +561,94 @@ private TestResult testGlobalVariable(Map assertion, String vari if (!Objects.equals(entry.getValue(), actual)) { hasError = true; errors.append(" ✘ expectedEvaluation.").append(entry.getKey()).append(": expected ") - .append(entry.getValue()).append(" but received ").append(actual).append("\n"); + .append(formatTestValue(entry.getValue())).append(" but received ").append(formatTestValue(actual)).append("\n"); + } + } + } + if (assertion.containsKey("children")) { + @SuppressWarnings("unchecked") List> children = (List>) assertion.get("children"); + for (int childIndex = 0; childIndex < children.size(); childIndex++) { + Map childAssertion = children.get(childIndex); + @SuppressWarnings("unchecked") Map childContext = (Map) childAssertion.getOrDefault("context", new HashMap<>()); + @SuppressWarnings("unchecked") Map childStickyFeatures = (Map) childAssertion.get("stickyFeatures"); + @SuppressWarnings("unchecked") Map childStickyVariables = (Map) childAssertion.get("stickyVariables"); + if (childStickyFeatures == null) childStickyFeatures = new HashMap<>(); + if (childStickyVariables == null) childStickyVariables = new HashMap<>(); + Featurevisor.SpawnOptions spawnOptions = new Featurevisor.SpawnOptions() + .stickyFeatures(childStickyFeatures) + .stickyVariables(childStickyVariables); + com.featurevisor.sdk.ChildInstance child = f.spawn(childContext, spawnOptions); + try { + TestResult childResult = testGlobalVariableChild(childAssertion, variableKey, child, childIndex); + hasError = hasError || childResult.hasError; + errors.append(childResult.errors); + } finally { + child.close(); } } } return new TestResult(hasError, errors.toString(), (System.nanoTime() - startTime) / 1_000_000.0); } + private TestResult testGlobalVariableChild(Map assertion, String variableKey, com.featurevisor.sdk.ChildInstance child, int childIndex) { + 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(); + 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"); + } + if (assertion.containsKey("expectedEvaluation")) { + @SuppressWarnings("unchecked") Map expected = (Map) assertion.get("expectedEvaluation"); + for (Map.Entry entry : expected.entrySet()) { + Object actual = getEvaluationValue(evaluation, entry.getKey()); + if (!Objects.equals(entry.getValue(), actual)) { + hasError = true; + errors.append(" ✘ ").append(prefix).append("expectedEvaluation.").append(entry.getKey()) + .append(": expected ").append(formatTestValue(entry.getValue())) + .append(" but received ").append(formatTestValue(actual)).append("\n"); + } + } + } + return new TestResult(hasError, errors.toString(), 0); + } + + private String formatTestValue(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception ignored) { + return String.valueOf(value); + } + } + + @SuppressWarnings("unchecked") + private Featurevisor createInstanceForAssertion(DatafileContent datafile, Map assertion, FeaturevisorLogLevel level) { + 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")); + 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)) + )); + } + return Featurevisor.createFeaturevisor(options); + } + + 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); + } + /** * Helper methods to work with both Instance and ChildInstance */ @@ -728,7 +811,7 @@ private void test() { @SuppressWarnings("unchecked") List> assertions = (List>) test.get("assertions"); - if (test.containsKey("feature") && !targets.isEmpty()) { + 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()); @@ -751,23 +834,17 @@ private void test() { String assertionEnvironment = assertion.get("environment") instanceof String ? (String) assertion.get("environment") : null; - String baseDatafileKey = getEnvironmentKey(assertionEnvironment); String selectedDatafileKey = selectDatafileKeyForAssertion(assertion, datafileCache); DatafileContent selectedDatafile = datafileCache.get(selectedDatafileKey); if (selectedDatafile == null) { - selectedDatafile = datafileCache.get(baseDatafileKey); - } - - if (selectedDatafile == null) { - throw new IOException("No datafile found for assertion environment: " + assertionEnvironment); - } - - @SuppressWarnings("unchecked") - Map effectiveAssertion = objectMapper.convertValue( - assertion, - new TypeReference>() {} - ); + testResult = missingDatafileResult(assertion); + } else { + @SuppressWarnings("unchecked") + Map effectiveAssertion = objectMapper.convertValue( + assertion, + new TypeReference>() {} + ); if (Boolean.TRUE.equals(showDatafile)) { System.out.println(); @@ -775,41 +852,33 @@ private void test() { System.out.println(); } - Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() - .datafile(selectedDatafile) - .logLevel(level)); - - // If "at" parameter is provided, create a new SDK instance with the specific module - if (effectiveAssertion.containsKey("at")) { - Object atObj = effectiveAssertion.get("at"); - double atValue; - - if (atObj instanceof Number) { - atValue = ((Number) atObj).doubleValue(); - } else { - atValue = Double.parseDouble(atObj.toString()); + Featurevisor f = createInstanceForAssertion(selectedDatafile, effectiveAssertion, level); + try { + testResult = testFeature(effectiveAssertion, (String) test.get("feature"), f, level); + } finally { + f.close(); } - - FeaturevisorModule testModule = new FeaturevisorModule("test-module") - .bucketValue((options) -> (int) (atValue * 1000)); - - f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() - .datafile(selectedDatafile) - .logLevel(level) - .modules(Collections.singletonList(testModule))); } - testResult = testFeature(effectiveAssertion, (String) test.get("feature"), f, level); - } else if (test.containsKey("variable")) { - 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) selectedDatafile = datafileCache.get(getEnvironmentKey(assertionEnvironment)); - if (selectedDatafile == null) throw new IOException("No datafile found for assertion environment: " + assertionEnvironment); - Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().datafile(selectedDatafile).logLevel(level)); - testResult = testGlobalVariable(assertion, (String) test.get("variable"), f); + 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 { 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 709a29f..8b323f6 100644 --- a/featurevisor-sdk/src/test/java/com/featurevisor/cli/CLIOptionsTest.java +++ b/featurevisor-sdk/src/test/java/com/featurevisor/cli/CLIOptionsTest.java @@ -64,7 +64,7 @@ public void testTargetAssertionSelectsTargetDatafile() { } @Test - public void testTargetAssertionFallsBackToBaseDatafile() { + public void testTargetAssertionDoesNotFallBackToBaseDatafile() { CLI cli = new CLI(); Map assertion = new HashMap<>(); assertion.put("environment", "production"); @@ -73,7 +73,7 @@ public void testTargetAssertionFallsBackToBaseDatafile() { Map cache = new HashMap<>(); cache.put("production", new DatafileContent("2", "base")); - assertEquals("production", cli.selectDatafileKeyForAssertion(assertion, cache)); + assertEquals("production-target-checkout", cli.selectDatafileKeyForAssertion(assertion, cache)); } @Test diff --git a/pom.xml b/pom.xml index aba7c45..5b8dc94 100644 --- a/pom.xml +++ b/pom.xml @@ -19,7 +19,7 @@ - 4.0.0 + 4.1.0 11 11 15