diff --git a/providers/flagd/CONTRIBUTING.md b/providers/flagd/CONTRIBUTING.md index 583b4be548..137fb9b9df 100644 --- a/providers/flagd/CONTRIBUTING.md +++ b/providers/flagd/CONTRIBUTING.md @@ -20,20 +20,14 @@ In vscode for instance, the following settings are recommended: ## End-to-End Tests -The continuous integration runs a set of [gherkin e2e tests](https://github.com/open-feature/test-harness/blob/main/features/evaluation.feature) using [`flagd`](https://github.com/open-feature/flagd). These tests do not run with the default maven profile. If you'd like to run them locally, you can start the flagd testbed with +The [flagd-testbed](https://github.com/open-feature/flagd-testbed) scenarios run through the +Provider TCK, which starts the testbed itself (Docker required): ``` -docker compose up -``` -and then run -``` -mvn test -P e2e +mvn -Ptck -pl providers/flagd -am test ``` -Note if your docker compose networking does not resolve to localhost, you can set a custom host -``` -FLAGD_HOST=192.168.100.1 mvn test -P e2e -``` +See [Provider conformance (TCK)](README.md#provider-conformance-tck) for what runs and how. If test-harness is being updated, commit the changes before running `mvn`. Otherwise, uncommitted changes will be overridden by maven test plugins. diff --git a/providers/flagd/README.md b/providers/flagd/README.md index 4daa06b84e..99b4dc2e18 100644 --- a/providers/flagd/README.md +++ b/providers/flagd/README.md @@ -375,9 +375,11 @@ mvn -Ptck -pl providers/flagd -am -Dtest=InProcessTest -Dsurefire.failIfNoSp The root pom's `tck` profile runs only the `tck` package, so `-am` adds no other module's tests. -**`-Pe2e` does not run these suites.** The root pom excludes every provider's `tck` package from -the default build and from `-Pe2e`, and its `tck` profile runs only that package. This module -excludes just `**/e2e/*.java`, which the `e2e` profile clears. +**These suites also carry the rest of flagd-testbed's scenarios**: its `tck-extensions/`, read +from the `test-harness` submodule and run through the TCK's extension hook, so they replace the +former `e2e` suites. `ConfigCucumberTest` still reads `config.feature` from the `test-harness` +submodule; it needs no backend and runs in the default build. The root pom excludes every +provider's `tck` package from the default build and from `-Pe2e`. **CI runs them in a job of their own**, `Provider TCK` in `ci.yml`, on every pull request. It sits apart from the `main` matrix so a conformance failure cannot be read as a regression there. Set diff --git a/providers/flagd/pom.xml b/providers/flagd/pom.xml index daf72975a4..2ea718e9e8 100644 --- a/providers/flagd/pom.xml +++ b/providers/flagd/pom.xml @@ -14,12 +14,6 @@ 0.15.0 - - **/e2e/*.java 1.85.0 3.25.6 @@ -182,6 +176,20 @@ + + + src/test/resources + + + + test-harness/tck-extensions + extensions + + *.feature + + + + org.codehaus.mojo @@ -269,16 +277,12 @@ - + e2e - - - - diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/ConfigCucumberTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/ConfigCucumberTest.java index d38c8339dd..29b543987d 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/ConfigCucumberTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/ConfigCucumberTest.java @@ -12,17 +12,17 @@ import org.junit.platform.suite.api.Suite; /** - * Class for running the tests associated with "stable" e2e tests (no fake disconnection) for the - * in-process provider + * Runs the flagd-testbed's config.feature, which checks option defaults, environment variables + * and builder validation. No backend is involved. */ @Order(value = Integer.MAX_VALUE) @Suite @IncludeEngines("cucumber") @SelectFile("test-harness/gherkin/config.feature") @ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "summary") -@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.providers.flagd.e2e.steps.config") +@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.providers.flagd.config") // The TCK ships its own Cucumber ObjectFactory, so two are on the test classpath and Cucumber -// refuses to guess. The e2e suites name the same one; this suite needs it for the same reason. +// refuses to guess. @ConfigurationParameter(key = OBJECT_FACTORY_PROPERTY_NAME, value = "io.cucumber.picocontainer.PicoFactory") // Config scenarios read System env vars in FlagdOptions.build() and some scenarios also // mutate them. Parallel execution causes env-var races (e.g. FLAGD_PORT=3456 leaking into diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/config/ConfigState.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/config/ConfigState.java new file mode 100644 index 0000000000..0a3d36167b --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/config/ConfigState.java @@ -0,0 +1,10 @@ +package dev.openfeature.contrib.providers.flagd.config; + +import dev.openfeature.contrib.providers.flagd.FlagdOptions; + +/** Scenario state shared by the config steps, one instance per scenario. */ +public class ConfigState { + public FlagdOptions options; + public FlagdOptions.FlagdOptionsBuilder builder = FlagdOptions.builder(); + public boolean hasError; +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/config/ConfigSteps.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/config/ConfigSteps.java similarity index 90% rename from providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/config/ConfigSteps.java rename to providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/config/ConfigSteps.java index 7418fe1b03..8e4cfece54 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/config/ConfigSteps.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/config/ConfigSteps.java @@ -1,12 +1,8 @@ -package dev.openfeature.contrib.providers.flagd.e2e.steps.config; +package dev.openfeature.contrib.providers.flagd.config; import static org.assertj.core.api.Assertions.assertThat; import dev.openfeature.contrib.providers.flagd.Config; -import dev.openfeature.contrib.providers.flagd.e2e.State; -import dev.openfeature.contrib.providers.flagd.e2e.steps.AbstractSteps; -import dev.openfeature.contrib.providers.flagd.e2e.steps.EnvironmentVariableUtils; -import dev.openfeature.contrib.providers.flagd.e2e.steps.Utils; import io.cucumber.java.en.Given; import io.cucumber.java.en.Then; import io.cucumber.java.en.When; @@ -19,7 +15,9 @@ import lombok.extern.slf4j.Slf4j; @Slf4j -public class ConfigSteps extends AbstractSteps { +public class ConfigSteps { + private final ConfigState state; + /** * Not all properties are correctly implemented, hence that we need to ignore them till this is * fixed @@ -30,8 +28,8 @@ public class ConfigSteps extends AbstractSteps { } }; - public ConfigSteps(State state) { - super(state); + public ConfigSteps(ConfigState state) { + this.state = state; } @When("a config was initialized") diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/EnvironmentVariableUtils.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/config/EnvironmentVariableUtils.java similarity index 98% rename from providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/EnvironmentVariableUtils.java rename to providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/config/EnvironmentVariableUtils.java index f7427cfa26..b493950962 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/EnvironmentVariableUtils.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/config/EnvironmentVariableUtils.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.flagd.e2e.steps; +package dev.openfeature.contrib.providers.flagd.config; /* * Copy of JUnit Pioneer's EnvironmentVariable Utils diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/Utils.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/config/Utils.java similarity index 97% rename from providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/Utils.java rename to providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/config/Utils.java index e53a83d290..c88eaac278 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/Utils.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/config/Utils.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.flagd.e2e.steps; +package dev.openfeature.contrib.providers.flagd.config; import dev.openfeature.contrib.providers.flagd.Config; import dev.openfeature.contrib.providers.flagd.resolver.rpc.cache.CacheType; diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/ContainerEntry.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/ContainerEntry.java deleted file mode 100644 index 820f808692..0000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/ContainerEntry.java +++ /dev/null @@ -1,50 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.time.Duration; -import org.apache.commons.io.FileUtils; -import org.apache.commons.lang3.RandomStringUtils; -import org.testcontainers.containers.ComposeContainer; -import org.testcontainers.containers.wait.strategy.Wait; - -/** A single pre-warmed Docker Compose stack (flagd + envoy) and its associated temp directory. */ -public class ContainerEntry { - - public static final int FORBIDDEN_PORT = 9212; - - public final ComposeContainer container; - public final Path tempDir; - - private ContainerEntry(ComposeContainer container, Path tempDir) { - this.container = container; - this.tempDir = tempDir; - } - - /** Start a new container entry. Blocks until all services are ready. */ - public static ContainerEntry start() throws IOException { - Path tempDir = Files.createDirectories( - Paths.get("tmp/" + RandomStringUtils.randomAlphanumeric(8).toLowerCase() + "/")); - - ComposeContainer container = new ComposeContainer(new File("test-harness/docker-compose.yaml")) - .withEnv("FLAGS_DIR", tempDir.toAbsolutePath().toString()) - .withExposedService("flagd", 8013, Wait.forListeningPort()) - .withExposedService("flagd", 8015, Wait.forListeningPort()) - .withExposedService("flagd", 8080, Wait.forListeningPort()) - .withExposedService("envoy", 9211, Wait.forListeningPort()) - .withExposedService("envoy", FORBIDDEN_PORT, Wait.forListeningPort()) - .withStartupTimeout(Duration.ofSeconds(45)); - container.start(); - - return new ContainerEntry(container, tempDir); - } - - /** Stop the container and clean up the temp directory. */ - public void stop() throws IOException { - container.stop(); - FileUtils.deleteDirectory(tempDir.toFile()); - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/ContainerPool.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/ContainerPool.java deleted file mode 100644 index 4d687c57c2..0000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/ContainerPool.java +++ /dev/null @@ -1,116 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.LinkedBlockingQueue; -import lombok.extern.slf4j.Slf4j; - -/** - * A pool of pre-warmed {@link ContainerEntry} instances. - * - *

All containers are started in parallel during {@link #initialize()}, paying the ~45s Docker - * Compose startup cost only once. Scenarios borrow a container via {@link #acquire()} and return - * it via {@link #release(ContainerEntry)} after teardown, allowing the next scenario to reuse it - * immediately without any cold-start overhead. - * - *

Pool size is controlled by the system property {@code flagd.e2e.pool.size} (default: 2). - * - *

Multiple test classes may share the same JVM fork (Surefire {@code reuseForks=true}). Each - * class calls {@link #initialize()} and {@link #shutdown()} once. A reference counter ensures - * that containers are only started on the first {@code initialize()} call and only stopped when - * the last {@code shutdown()} call is made, preventing one class from destroying containers that - * are still in use by another class running concurrently in the same JVM. - */ -@Slf4j -public class ContainerPool { - - private static final int POOL_SIZE = Integer.getInteger( - "flagd.e2e.pool.size", Math.min(Runtime.getRuntime().availableProcessors(), 4)); - - private static final BlockingQueue pool = new LinkedBlockingQueue<>(); - private static final List all = new ArrayList<>(); - private static final java.util.concurrent.atomic.AtomicInteger refCount = - new java.util.concurrent.atomic.AtomicInteger(0); - - public static void initialize() throws Exception { - if (refCount.getAndIncrement() > 0) { - log.info("Container pool already initialized (refCount={}), reusing existing pool.", refCount.get()); - return; - } - log.info("Starting container pool of size {}...", POOL_SIZE); - ExecutorService executor = Executors.newFixedThreadPool(POOL_SIZE); - List> futures = new ArrayList<>(); - try { - for (int i = 0; i < POOL_SIZE; i++) { - futures.add(executor.submit(ContainerEntry::start)); - } - for (Future future : futures) { - ContainerEntry entry = future.get(); - pool.add(entry); - all.add(entry); - } - } catch (Exception e) { - // Collect any containers that started successfully (including from in-flight futures) - for (Future future : futures) { - try { - ContainerEntry entry = future.get(); - if (!all.contains(entry)) { - all.add(entry); - } - } catch (Exception ignored) { - // future failed; nothing to stop for it - } - } - all.forEach(entry -> { - try { - entry.stop(); - } catch (IOException suppressed) { - e.addSuppressed(suppressed); - } - }); - pool.clear(); - all.clear(); - refCount.decrementAndGet(); - throw e; - } finally { - executor.shutdown(); - } - log.info("Container pool ready ({} containers).", POOL_SIZE); - } - - public static void shutdown() { - int remaining = refCount.decrementAndGet(); - if (remaining > 0) { - log.info("Container pool still in use by {} class(es), deferring shutdown.", remaining); - return; - } - log.info("Last shutdown call — stopping all containers."); - all.forEach(entry -> { - try { - entry.stop(); - } catch (IOException e) { - log.warn("Error stopping container entry", e); - } - }); - pool.clear(); - all.clear(); - } - - /** - * Borrow a container from the pool, blocking until one becomes available. - * The caller MUST call {@link #release(ContainerEntry)} when done. - */ - public static ContainerEntry acquire() throws InterruptedException { - return pool.take(); - } - - /** Return a container to the pool so the next scenario can use it. */ - public static void release(ContainerEntry entry) { - pool.add(entry); - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/ContainerUtil.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/ContainerUtil.java deleted file mode 100644 index b639672230..0000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/ContainerUtil.java +++ /dev/null @@ -1,32 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e; - -import dev.openfeature.contrib.providers.flagd.Config; -import java.util.Optional; -import org.testcontainers.containers.ComposeContainer; -import org.testcontainers.containers.ContainerState; - -public class ContainerUtil { - public static int getPort(ComposeContainer container, Config.Resolver resolver) { - Optional flagd = container.getContainerByServiceName("flagd"); - - return flagd.map(containerState -> { - switch (resolver) { - case RPC: - return containerState.getMappedPort(8013); - case IN_PROCESS: - return containerState.getMappedPort(8015); - default: - return 0; - } - }) - .orElseThrow(() -> new RuntimeException("Could not map port")); - } - - public static String getLaunchpadUrl(ComposeContainer container) { - Optional flagd = container.getContainerByServiceName("flagd"); - return flagd.map(containerState -> { - return containerState.getHost() + ":" + containerState.getMappedPort(8080); - }) - .orElseThrow(() -> new RuntimeException("Could not find launchpad url")); - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFileTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFileTest.java deleted file mode 100644 index c7387b6e33..0000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFileTest.java +++ /dev/null @@ -1,51 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e; - -import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME; -import static io.cucumber.junit.platform.engine.Constants.OBJECT_FACTORY_PROPERTY_NAME; -import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME; - -import dev.openfeature.contrib.providers.flagd.Config; -import org.apache.logging.log4j.core.config.Order; -import org.junit.platform.suite.api.BeforeSuite; -import org.junit.platform.suite.api.ConfigurationParameter; -import org.junit.platform.suite.api.ExcludeTags; -import org.junit.platform.suite.api.IncludeEngines; -import org.junit.platform.suite.api.IncludeTags; -import org.junit.platform.suite.api.SelectDirectories; -import org.junit.platform.suite.api.Suite; -import org.testcontainers.junit.jupiter.Testcontainers; - -/** - * Class for running the reconnection tests for the RPC provider - */ -@Order(value = Integer.MAX_VALUE) -@Suite -@IncludeEngines("cucumber") -@SelectDirectories("test-harness/gherkin") -// if you want to run just one feature file, use the following line instead of @SelectDirectories -// @SelectFile("test-harness/gherkin/connection.feature") -@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "summary") -@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.providers.flagd.e2e.steps") -@ConfigurationParameter(key = OBJECT_FACTORY_PROPERTY_NAME, value = "io.cucumber.picocontainer.PicoFactory") -@IncludeTags("file") -@ExcludeTags({ - "unixsocket", - "targetURI", - "reconnect", - "customCert", - "events", - "contextEnrichment", - "fractional-v1", - "fractional-v2", - "deprecated", - // reads 2^53-1 via 32-bit getInteger; Long scenarios: open-feature/flagd-testbed#379 - "large-integers" -}) -@Testcontainers -public class RunFileTest { - - @BeforeSuite - public static void before() { - State.resolverType = Config.Resolver.FILE; - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java deleted file mode 100644 index 05eb219db1..0000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java +++ /dev/null @@ -1,40 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e; - -import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME; -import static io.cucumber.junit.platform.engine.Constants.OBJECT_FACTORY_PROPERTY_NAME; -import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME; - -import dev.openfeature.contrib.providers.flagd.Config; -import org.apache.logging.log4j.core.config.Order; -import org.junit.platform.suite.api.BeforeSuite; -import org.junit.platform.suite.api.ConfigurationParameter; -import org.junit.platform.suite.api.ExcludeTags; -import org.junit.platform.suite.api.IncludeEngines; -import org.junit.platform.suite.api.IncludeTags; -import org.junit.platform.suite.api.SelectDirectories; -import org.junit.platform.suite.api.Suite; -import org.testcontainers.junit.jupiter.Testcontainers; - -/** - * Class for running the reconnection tests for the RPC provider - */ -@Order(value = Integer.MAX_VALUE) -@Suite -@IncludeEngines("cucumber") -@SelectDirectories("test-harness/gherkin") -// if you want to run just one feature file, use the following line instead of @SelectDirectories -// @SelectFile("test-harness/gherkin/selector.feature") -@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "summary") -@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.providers.flagd.e2e.steps") -@ConfigurationParameter(key = OBJECT_FACTORY_PROPERTY_NAME, value = "io.cucumber.picocontainer.PicoFactory") -@IncludeTags("in-process") -// large-integers reads 2^53-1 via 32-bit getInteger; Long scenarios: open-feature/flagd-testbed#379 -@ExcludeTags({"unixsocket", "fractional-v1", "fractional-v2", "deprecated", "large-integers"}) -@Testcontainers -public class RunInProcessTest { - - @BeforeSuite - public static void before() { - State.resolverType = Config.Resolver.IN_PROCESS; - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java deleted file mode 100644 index 4fe721d093..0000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java +++ /dev/null @@ -1,40 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e; - -import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME; -import static io.cucumber.junit.platform.engine.Constants.OBJECT_FACTORY_PROPERTY_NAME; -import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME; - -import dev.openfeature.contrib.providers.flagd.Config; -import org.apache.logging.log4j.core.config.Order; -import org.junit.platform.suite.api.BeforeSuite; -import org.junit.platform.suite.api.ConfigurationParameter; -import org.junit.platform.suite.api.ExcludeTags; -import org.junit.platform.suite.api.IncludeEngines; -import org.junit.platform.suite.api.IncludeTags; -import org.junit.platform.suite.api.SelectDirectories; -import org.junit.platform.suite.api.Suite; -import org.testcontainers.junit.jupiter.Testcontainers; - -/** - * Class for running the reconnection tests for the RPC provider - */ -@Order(value = Integer.MAX_VALUE) -@Suite -@IncludeEngines("cucumber") -@SelectDirectories("test-harness/gherkin") -// if you want to run just one feature file, use the following line instead of @SelectDirectories -// @SelectFile("test-harness/gherkin/rpc-caching.feature") -@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "summary") -@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.providers.flagd.e2e.steps") -@ConfigurationParameter(key = OBJECT_FACTORY_PROPERTY_NAME, value = "io.cucumber.picocontainer.PicoFactory") -@IncludeTags({"rpc"}) -// large-integers reads 2^53-1 via 32-bit getInteger; Long scenarios: open-feature/flagd-testbed#379 -@ExcludeTags({"unixsocket", "fractional-v1", "fractional-v2", "deprecated", "large-integers"}) -@Testcontainers -public class RunRpcTest { - - @BeforeSuite - public static void before() { - State.resolverType = Config.Resolver.RPC; - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/State.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/State.java deleted file mode 100644 index 2c671d5425..0000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/State.java +++ /dev/null @@ -1,36 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e; - -import dev.openfeature.contrib.providers.flagd.Config; -import dev.openfeature.contrib.providers.flagd.FlagdOptions; -import dev.openfeature.contrib.providers.flagd.e2e.steps.Event; -import dev.openfeature.contrib.providers.flagd.e2e.steps.FlagSteps; -import dev.openfeature.contrib.providers.flagd.e2e.steps.ProviderType; -import dev.openfeature.sdk.Client; -import dev.openfeature.sdk.FeatureProvider; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.MutableContext; -import java.util.Optional; -import java.util.concurrent.ConcurrentLinkedQueue; - -public class State { - public ProviderType providerType; - public Client client; - public FeatureProvider provider; - /** The domain name under which this scenario's provider is registered with OpenFeatureAPI. */ - public String providerName; - /** The container borrowed from {@link ContainerPool} for this scenario. */ - public ContainerEntry containerEntry; - - // events not yet consumed by a positive assertion; drained as they are matched - public ConcurrentLinkedQueue assertedEvents = new ConcurrentLinkedQueue<>(); - // complete log of every event emitted, used for "never fired" assertions - public ConcurrentLinkedQueue allEvents = new ConcurrentLinkedQueue<>(); - public Optional lastEvent; - public FlagSteps.Flag flag; - public MutableContext context = new MutableContext(); - public FlagEvaluationDetails evaluation; - public FlagdOptions options; - public FlagdOptions.FlagdOptionsBuilder builder = FlagdOptions.builder(); - public static Config.Resolver resolverType; - public boolean hasError; -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/AbstractSteps.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/AbstractSteps.java deleted file mode 100644 index 4a74400c48..0000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/AbstractSteps.java +++ /dev/null @@ -1,11 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e.steps; - -import dev.openfeature.contrib.providers.flagd.e2e.State; - -public abstract class AbstractSteps { - protected State state; - - protected AbstractSteps(State state) { - this.state = state; - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ContextSteps.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ContextSteps.java deleted file mode 100644 index b92c994b7e..0000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ContextSteps.java +++ /dev/null @@ -1,53 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e.steps; - -import dev.openfeature.contrib.providers.flagd.e2e.State; -import dev.openfeature.sdk.ImmutableStructure; -import dev.openfeature.sdk.MutableContext; -import dev.openfeature.sdk.Value; -import io.cucumber.datatable.DataTable; -import io.cucumber.java.en.Given; -import java.io.IOException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -public class ContextSteps extends AbstractSteps { - - public ContextSteps(State state) { - super(state); - } - - @Given("^a context containing a key \"([^\"]*)\", with type \"([^\"]*)\" and with value \"(.*)\"$") - public void a_context_containing_a_key_with_type_and_with_value(String key, String type, String value) - throws ClassNotFoundException, IOException { - Map map = new HashMap<>(state.context.asMap()); - map.put(key, Value.objectToValue(Utils.convert(value, type))); - state.context = new MutableContext(state.context.getTargetingKey(), map); - } - - @Given("a context with the following keys:") - public void a_context_with_the_following_keys(DataTable dataTable) throws ClassNotFoundException, IOException { - List> rows = dataTable.asMaps(String.class, String.class); - Map map = new HashMap<>(state.context.asMap()); - for (Map row : rows) { - String key = row.get("key"); - String type = row.get("type"); - String value = row.get("value"); - map.put(key, Value.objectToValue(Utils.convert(value, type))); - } - state.context = new MutableContext(state.context.getTargetingKey(), map); - } - - @Given("a context containing a targeting key with value {string}") - public void a_context_containing_a_targeting_key_with_value(String string) { - state.context.setTargetingKey(string); - } - - @Given("a context containing a nested property with outer key {string} and inner key {string}, with value {string}") - public void a_context_containing_a_nested_property_with_outer_key_and_inner_key_with_value( - String outer, String inner, String value) { - Map innerMap = new HashMap<>(); - innerMap.put(inner, new Value(value)); - state.context.add(outer, new ImmutableStructure(innerMap)); - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/Event.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/Event.java deleted file mode 100644 index f93c327e6e..0000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/Event.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e.steps; - -import dev.openfeature.sdk.EventDetails; - -public class Event { - public String type; - public EventDetails details; - - public Event(String eventType, EventDetails eventDetails) { - this.type = eventType; - this.details = eventDetails; - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/EventSteps.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/EventSteps.java deleted file mode 100644 index 9c3fd91163..0000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/EventSteps.java +++ /dev/null @@ -1,89 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e.steps; - -import static java.util.concurrent.TimeUnit.MILLISECONDS; -import static org.assertj.core.api.Assertions.assertThat; -import static org.awaitility.Awaitility.await; - -import dev.openfeature.contrib.providers.flagd.e2e.State; -import dev.openfeature.sdk.ProviderEvent; -import io.cucumber.java.en.Given; -import io.cucumber.java.en.Then; -import io.cucumber.java.en.When; -import lombok.extern.slf4j.Slf4j; -import org.jetbrains.annotations.NotNull; - -@Slf4j -public class EventSteps extends AbstractSteps { - - private static final int EVENT_TIMEOUT_MS = 12_000; - - public EventSteps(State state) { - super(state); - } - - @Given("a {} event handler") - public void a_stale_event_handler(String eventType) { - state.client.on(mapEventType(eventType), eventDetails -> { - log.info("{} event tracked", eventType); - Event event = new Event(eventType, eventDetails); - state.allEvents.add(event); - state.assertedEvents.add(event); - }); - } - - private static @NotNull ProviderEvent mapEventType(String eventType) { - switch (eventType) { - case "stale": - return ProviderEvent.PROVIDER_STALE; - case "ready": - return ProviderEvent.PROVIDER_READY; - case "error": - return ProviderEvent.PROVIDER_ERROR; - case "change": - return ProviderEvent.PROVIDER_CONFIGURATION_CHANGED; - default: - throw new IllegalArgumentException("Unknown event type: " + eventType); - } - } - - @When("a {} event was fired") - public void eventWasFired(String eventType) { - eventHandlerShouldBeExecutedWithin(eventType, EVENT_TIMEOUT_MS); - } - - @Then("the {} event handler should have been executed") - public void eventHandlerShouldBeExecuted(String eventType) { - eventHandlerShouldBeExecutedWithin(eventType, EVENT_TIMEOUT_MS); - } - - @Then("the {} event handler should not have been executed") - public void eventHandlerShouldNotHaveBeenExecuted(String eventType) { - // checks the full log, not assertedEvents: preceding positive - // assertions may have drained an intervening event of this type - assertThat(state.allEvents.stream().anyMatch(event -> event.type.equals(eventType))) - .as("no %s event should have fired", eventType) - .isFalse(); - } - - @Then("the {} event handler should have been executed within {int}ms") - public void eventHandlerShouldBeExecutedWithin(String eventType, int ms) { - log.info("waiting for eventtype: {}", eventType); - await().alias("waiting for eventtype " + eventType) - .atMost(ms, MILLISECONDS) - .pollInterval(10, MILLISECONDS) - .until(() -> state.assertedEvents.stream().anyMatch(event -> event.type.equals(eventType))); - // Drain all events up to and including the first match. This ensures that - // older events (e.g. a READY from before a disconnect) cannot satisfy a - // later assertion that expects a *new* event of the same type, while still - // preserving events that arrived *after* the match for subsequent steps. - Event matched = null; - while (!state.assertedEvents.isEmpty()) { - Event head = state.assertedEvents.poll(); - if (head != null && head.type.equals(eventType)) { - matched = head; - break; - } - } - state.lastEvent = java.util.Optional.ofNullable(matched); - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/FlagSteps.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/FlagSteps.java deleted file mode 100644 index 462fa13819..0000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/FlagSteps.java +++ /dev/null @@ -1,148 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e.steps; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.openfeature.contrib.providers.flagd.e2e.State; -import dev.openfeature.sdk.ErrorCode; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.ImmutableMetadata; -import dev.openfeature.sdk.Value; -import io.cucumber.java.en.Given; -import io.cucumber.java.en.Then; -import io.cucumber.java.en.When; -import java.io.IOException; -import java.lang.reflect.Field; -import java.util.List; -import java.util.Map; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -public class FlagSteps extends AbstractSteps { - - public FlagSteps(State state) { - super(state); - } - - @Given("a {}-flag with key {string} and a default value {string}") - public void givenAFlag(String type, String name, String defaultValue) throws Throwable { - state.flag = new Flag(type, name, Utils.convert(defaultValue, type)); - } - - @When("the flag was evaluated with details") - public void the_flag_was_evaluated_with_details() throws InterruptedException { - FlagEvaluationDetails details; - switch (state.flag.type) { - case "String": - details = - state.client.getStringDetails(state.flag.name, (String) state.flag.defaultValue, state.context); - break; - case "Boolean": - details = state.client.getBooleanDetails( - state.flag.name, (Boolean) state.flag.defaultValue, state.context); - break; - case "Float": - details = - state.client.getDoubleDetails(state.flag.name, (Double) state.flag.defaultValue, state.context); - break; - case "Integer": - details = state.client.getIntegerDetails( - state.flag.name, (Integer) state.flag.defaultValue, state.context); - break; - case "Object": - details = - state.client.getObjectDetails(state.flag.name, (Value) state.flag.defaultValue, state.context); - break; - default: - throw new AssertionError(); - } - state.evaluation = details; - } - - @Then("the resolved details value should be \"{}\"") - public void the_resolved_details_value_should_be(String value) throws Throwable { - if (state.evaluation.getErrorCode() != null) { - log.warn(state.evaluation.getErrorMessage()); - } - assertThat(state.evaluation.getValue()).isEqualTo(Utils.convert(value, state.flag.type)); - } - - @Then("the reason should be {string}") - public void the_reason_should_be(String reason) { - assertThat(state.evaluation.getReason()).isEqualTo(reason); - } - - @Then("the variant should be {string}") - public void the_variant_should_be(String variant) { - assertThat(state.evaluation.getVariant()).isEqualTo(variant); - } - - @Then("the error-code should be {string}") - public void the_error_code_should_be(String errorCode) { - if (errorCode.isEmpty()) { - assertThat(state.evaluation.getErrorCode()).isNull(); - } else { - assertThat(state.evaluation.getErrorCode()).isEqualTo(ErrorCode.valueOf(errorCode)); - } - } - - @Then("the flag should be part of the event payload") - public void the_flag_was_modified() { - Event event = state.lastEvent.orElseThrow(AssertionError::new); - assertThat(event.details.getFlagsChanged()).contains(state.flag.name); - } - - public class Flag { - String name; - Object defaultValue; - String type; - - public Flag(String type, String name, Object defaultValue) { - this.name = name; - this.defaultValue = defaultValue; - this.type = type; - } - } - - @Then("the resolved metadata is empty") - @SuppressWarnings("unchecked") - public void the_resolved_metadata_is_empty() throws NoSuchFieldException, IllegalAccessException { - ImmutableMetadata flagMetadata = state.evaluation.getFlagMetadata(); - - Field metadataField = flagMetadata.getClass().getDeclaredField("metadata"); - metadataField.setAccessible(true); - Map metadataMap = (Map) metadataField.get(flagMetadata); - assertThat(metadataMap).isEmpty(); - } - - @Then("the resolved metadata should contain") - @SuppressWarnings("unchecked") - public void the_resolved_metadata_should_contain(io.cucumber.datatable.DataTable dataTable) - throws IOException, ClassNotFoundException { - - ImmutableMetadata flagMetadata = state.evaluation.getFlagMetadata(); - - List> rows = dataTable.asMaps(String.class, String.class); - for (Map row : rows) { - switch (row.get("metadata_type")) { - case "String": - assertThat(flagMetadata.getString(row.get("key"))) - .isEqualTo(Utils.convert(row.get("value"), row.get("metadata_type"))); - break; - case "Boolean": - assertThat(flagMetadata.getBoolean(row.get("key"))) - .isEqualTo(Utils.convert(row.get("value"), row.get("metadata_type"))); - break; - case "Float": - assertThat(flagMetadata.getDouble(row.get("key"))) - .isEqualTo(Utils.convert(row.get("value"), row.get("metadata_type"))); - break; - case "Integer": - assertThat(flagMetadata.getInteger(row.get("key"))) - .isEqualTo(Utils.convert(row.get("value"), row.get("metadata_type"))); - break; - default: - throw new AssertionError("type not supported"); - } - } - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ProviderSteps.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ProviderSteps.java deleted file mode 100644 index f9e1b846f1..0000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ProviderSteps.java +++ /dev/null @@ -1,206 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e.steps; - -import static io.restassured.RestAssured.when; -import static org.assertj.core.api.Assertions.assertThat; - -import dev.openfeature.contrib.providers.flagd.Config; -import dev.openfeature.contrib.providers.flagd.FlagdOptions; -import dev.openfeature.contrib.providers.flagd.FlagdProvider; -import dev.openfeature.contrib.providers.flagd.e2e.ContainerEntry; -import dev.openfeature.contrib.providers.flagd.e2e.ContainerPool; -import dev.openfeature.contrib.providers.flagd.e2e.ContainerUtil; -import dev.openfeature.contrib.providers.flagd.e2e.State; -import dev.openfeature.sdk.FeatureProvider; -import dev.openfeature.sdk.NoOpProvider; -import dev.openfeature.sdk.OpenFeatureAPI; -import dev.openfeature.sdk.ProviderState; -import io.cucumber.java.After; -import io.cucumber.java.AfterAll; -import io.cucumber.java.BeforeAll; -import io.cucumber.java.en.Given; -import io.cucumber.java.en.Then; -import io.cucumber.java.en.When; -import java.io.File; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.StringUtils; -import org.testcontainers.containers.ComposeContainer; - -@Slf4j -public class ProviderSteps extends AbstractSteps { - - public static final int UNAVAILABLE_PORT = 9999; - - public ProviderSteps(State state) { - super(state); - } - - @BeforeAll - public static void beforeAll() throws Exception { - ContainerPool.initialize(); - } - - @AfterAll - public static void afterAll() { - ContainerPool.shutdown(); - } - - @After - public void tearDown() { - if (state.containerEntry != null) { - if (state.client != null) { - when().post("http://" + ContainerUtil.getLaunchpadUrl(state.containerEntry.container) + "/stop") - .then() - .statusCode(200); - } - ContainerPool.release(state.containerEntry); - state.containerEntry = null; - } - // Replace the domain provider with a NoOp through the SDK lifecycle so the SDK - // properly calls detachEventProvider (nulls onEmit) and shuts down the emitter - // executor — neither of which happens when calling provider.shutdown() directly. - if (state.providerName != null) { - OpenFeatureAPI.getInstance().setProvider(state.providerName, new NoOpProvider()); - } - } - - @Given("a {} flagd provider") - public void setupProvider(String providerType) throws InterruptedException { - state.containerEntry = ContainerPool.acquire(); - ComposeContainer container = state.containerEntry.container; - - String flagdConfig = "default"; - state.builder - .deadline(1000) - .keepAlive(0) - .retryGracePeriod(5) - .retryBackoffMs(500) - .retryBackoffMaxMs(500); - boolean wait = true; - - switch (providerType) { - case "unavailable": - this.state.providerType = ProviderType.SOCKET; - state.builder.port(UNAVAILABLE_PORT); - if (State.resolverType == Config.Resolver.FILE) { - state.builder.offlineFlagSourcePath("not-existing"); - } - wait = false; - break; - case "forbidden": - state.builder.port(container.getServicePort("envoy", ContainerEntry.FORBIDDEN_PORT)); - wait = false; - break; - case "socket": - this.state.providerType = ProviderType.SOCKET; - String socketPath = state.containerEntry - .tempDir - .resolve("socket.sock") - .toAbsolutePath() - .toString(); - state.builder.socketPath(socketPath); - state.builder.port(UNAVAILABLE_PORT); - break; - case "ssl": - String path = "test-harness/ssl/custom-root-cert.crt"; - File file = new File(path); - String absolutePath = file.getAbsolutePath(); - this.state.providerType = ProviderType.SSL; - state.builder - .port(ContainerUtil.getPort(container, State.resolverType)) - .tls(true) - .certPath(absolutePath); - flagdConfig = "ssl"; - break; - case "metadata": - flagdConfig = "metadata"; - if (State.resolverType == Config.Resolver.FILE) { - FlagdOptions build = state.builder.build(); - String selector = build.getSelector(); - String replace = selector.replace("rawflags/", ""); - state.builder - .port(UNAVAILABLE_PORT) - .offlineFlagSourcePath(new File("test-harness/flags/" + replace).getAbsolutePath()); - } else { - state.builder.port(ContainerUtil.getPort(container, State.resolverType)); - } - break; - case "syncpayload": - flagdConfig = "sync-payload"; - state.builder.port(ContainerUtil.getPort(container, State.resolverType)); - break; - case "stable": - this.state.providerType = ProviderType.DEFAULT; - if (State.resolverType == Config.Resolver.FILE) { - state.builder - .port(UNAVAILABLE_PORT) - .offlineFlagSourcePath(state.containerEntry - .tempDir - .resolve("allFlags.json") - .toAbsolutePath() - .toString()); - } else { - state.builder.port(ContainerUtil.getPort(container, State.resolverType)); - } - break; - default: - throw new IllegalStateException(); - } - - // Setting TargetUri if this setting is set - FlagdOptions tempBuild = state.builder.build(); - if (!StringUtils.isEmpty(tempBuild.getTargetUri())) { - String replace = tempBuild.getTargetUri().replace("", "" + container.getServicePort("envoy", 9211)); - state.builder.targetUri(replace); - state.builder.port(UNAVAILABLE_PORT); - } - - when().post("http://" + ContainerUtil.getLaunchpadUrl(container) + "/start?config={config}", flagdConfig) - .then() - .statusCode(200); - - Thread.sleep(50); - - FeatureProvider provider = - new FlagdProvider(state.builder.resolverType(State.resolverType).build()); - String providerName = "Provider " + Math.random(); - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - - if (wait) { - api.setProviderAndWait(providerName, provider); - } else { - api.setProvider(providerName, provider); - } - this.state.provider = provider; - this.state.providerName = providerName; - this.state.client = api.getClient(providerName); - } - - @When("the connection is lost") - public void the_connection_is_lost() { - when().post("http://" + ContainerUtil.getLaunchpadUrl(state.containerEntry.container) + "/stop") - .then() - .statusCode(200); - } - - @When("the connection is lost for {int}s") - public void the_connection_is_lost_for(int seconds) { - when().post( - "http://" + ContainerUtil.getLaunchpadUrl(state.containerEntry.container) - + "/restart?seconds={seconds}", - seconds) - .then() - .statusCode(200); - } - - @When("the flag was modified") - public void the_flag_was_modded() { - when().post("http://" + ContainerUtil.getLaunchpadUrl(state.containerEntry.container) + "/change") - .then() - .statusCode(200); - } - - @Then("the client should be in {} state") - public void the_client_should_be_in_fatal_state(String clientState) { - assertThat(state.client.getProviderState()).isEqualTo(ProviderState.valueOf(clientState.toUpperCase())); - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ProviderType.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ProviderType.java deleted file mode 100644 index eac0ba9674..0000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ProviderType.java +++ /dev/null @@ -1,7 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e.steps; - -public enum ProviderType { - DEFAULT, - SSL, - SOCKET -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/tck/AbstractResolverTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/tck/AbstractResolverTest.java index f8a9b1b545..8abd9178b4 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/tck/AbstractResolverTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/tck/AbstractResolverTest.java @@ -8,9 +8,13 @@ import dev.openfeature.contrib.tools.tck.ContainerizedProviderTckTest; import dev.openfeature.sdk.FeatureProvider; import java.io.File; +import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; /** * Shared configuration for running the OpenFeature Provider TCK against the flagd provider. @@ -20,7 +24,7 @@ * the ruleset off the disk. All three share a backend stack; the two gRPC modes differ only in * resolver and port, and {@link FileTest} says what the third needs instead. */ -abstract class AbstractResolverTest extends ContainerizedProviderTckTest { +abstract class AbstractResolverTest extends ContainerizedProviderTckTest implements FlagdTckHarness { /** A port nothing listens on; an outage on the Compose stack belongs to the control API. */ private static final int UNAVAILABLE_PORT = 9999; @@ -36,8 +40,11 @@ abstract class AbstractResolverTest extends ContainerizedProviderTckTest { /** gRPC deadline for a dead port; these scenarios assert that the failure is reported promptly. */ private static final int UNAVAILABLE_DEADLINE_MS = 1000; + /** Per-scenario option changes recorded by the extension steps; see {@link FlagdTckHarness}. */ + private final List> customizations = new CopyOnWriteArrayList<>(); + /** The resolver under test. */ - protected abstract Config.Resolver resolver(); + public abstract Config.Resolver resolver(); /** * The container-internal port this mode waits on before the first scenario. @@ -51,7 +58,9 @@ abstract class AbstractResolverTest extends ContainerizedProviderTckTest { * {@inheritDoc} * *

The flagd-testbed's own stack, from the {@code test-harness} submodule the legacy e2e suites - * also run, so the image tag follows the submodule pin instead of a second copy that can drift. + * also run, so the image tag follows the submodule. It adds an envoy sidecar, which the + * {@code @targetURI} and {@code @forbidden} extension scenarios connect through; no canonical + * scenario reaches it. */ @Override public File composeFile() { @@ -64,6 +73,12 @@ public String backendService() { return "flagd"; } + /** The envoy sidecar's two listeners; see {@link #composeFile()}. */ + @Override + public Map> additionalPorts() { + return Collections.singletonMap(ENVOY_SERVICE, Arrays.asList(ENVOY_ROUTING_PORT, ENVOY_FORBIDDEN_PORT)); + } + @Override public List backendPorts() { return Collections.singletonList(backendPort()); @@ -71,13 +86,13 @@ public List backendPorts() { @Override public FeatureProvider createProvider(BackendEndpoint endpoint) { - return new FlagdProvider(flagSource(baseOptions().deadline(CONNECTED_DEADLINE_MS), endpoint) + return new FlagdProvider(customized(flagSource(baseOptions().deadline(CONNECTED_DEADLINE_MS), endpoint)) .build()); } @Override public FeatureProvider createUnavailableProvider() { - return new FlagdProvider(missingFlagSource(baseOptions().deadline(UNAVAILABLE_DEADLINE_MS)) + return new FlagdProvider(customized(missingFlagSource(baseOptions().deadline(UNAVAILABLE_DEADLINE_MS))) .build()); } @@ -103,6 +118,26 @@ protected FlagdOptions.FlagdOptionsBuilder missingFlagSource(FlagdOptions.FlagdO return options.host("localhost").port(UNAVAILABLE_PORT); } + @Override + public void customize(Consumer customization) { + customizations.add(customization); + } + + @Override + public void resetCustomizations() { + customizations.clear(); + } + + @Override + public BackendEndpoint backendEndpoint() { + return endpoint(); + } + + @Override + public String launchpadUrl() { + return "http://" + endpoint().host() + ":" + endpoint().port(controlPort()); + } + /** * {@inheritDoc} * @@ -120,6 +155,11 @@ public Set capabilities() { return Capability.declarableExcept(Capability.REINITIALIZATION); } + private FlagdOptions.FlagdOptionsBuilder customized(FlagdOptions.FlagdOptionsBuilder builder) { + customizations.forEach(customization -> customization.accept(builder)); + return builder; + } + private FlagdOptions.FlagdOptionsBuilder baseOptions() { return FlagdOptions.builder() .resolverType(resolver()) diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/tck/FileTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/tck/FileTest.java index 6336d67719..7c2da53817 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/tck/FileTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/tck/FileTest.java @@ -4,6 +4,8 @@ import dev.openfeature.contrib.providers.flagd.FlagdOptions; import dev.openfeature.contrib.tools.tck.BackendEndpoint; import dev.openfeature.contrib.tools.tck.Capability; +import dev.openfeature.contrib.tools.tck.TckExcludeTags; +import dev.openfeature.contrib.tools.tck.TckIncludeTags; import java.io.IOException; import java.io.UncheckedIOException; import java.nio.file.Files; @@ -21,7 +23,14 @@ * file where this JVM can see it. The testbed's Compose file mounts {@code $FLAGS_DIR} at * {@code /flags}, where its launchpad writes {@code allFlags.json}, so naming a host directory * through {@link #composeEnvironment()} is all it takes. + * + *

Three extension exclusions: {@code @ext-connection}, whose every scenario turns on a gRPC + * connection this resolver does not have; {@code @metadata-provider}, which names a + * {@code selector-} flag file the launchpad leaves out of the combined {@code allFlags.json}; and + * {@code @fractional-v2}, as in the other two suites. */ +@TckIncludeTags("@file") +@TckExcludeTags({"@ext-connection", "@metadata-provider", "@fractional-v2"}) public class FileTest extends AbstractResolverTest { /** diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/tck/FlagdTckHarness.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/tck/FlagdTckHarness.java new file mode 100644 index 0000000000..dfca90d48e --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/tck/FlagdTckHarness.java @@ -0,0 +1,48 @@ +package dev.openfeature.contrib.providers.flagd.tck; + +import dev.openfeature.contrib.providers.flagd.FlagdOptions; +import dev.openfeature.contrib.tools.tck.BackendEndpoint; +import java.util.function.Consumer; + +/** + * What the flagd extension scenarios, flagd-testbed's {@code tck-extensions/} on the classpath, + * need from the adoption beyond the TCK's own harness. + * + *

The TCK builds every provider through {@code createProvider()} with no arguments, which suits + * the canonical scenarios: they ask the same question of every provider. The extensions port the + * flagd-testbed scenarios, which configure the provider per scenario (a cache, a selector, a grace + * period). Rather than a second way of creating a provider, a scenario records its configuration + * here and the TCK's own {@code Given a stable provider} applies it. + * + *

Step classes receive the harness by injection and cast it to this interface. + */ +public interface FlagdTckHarness { + + /** The envoy sidecar's Compose service, exposed through {@link #backendEndpoint()}. */ + String ENVOY_SERVICE = "envoy"; + + /** envoy's listener that routes {@code rpc.service} and {@code sync.service} to flagd. */ + int ENVOY_ROUTING_PORT = 9211; + + /** envoy's listener that answers every call with HTTP 403, gRPC {@code PERMISSION_DENIED}. */ + int ENVOY_FORBIDDEN_PORT = 9212; + + /** + * Records a change to the options of the next provider this scenario creates. + * + *

Applied after the adoption's defaults, in the order recorded, so a scenario can override + * any of them. Cleared after every scenario. + * + * @param customization a change to the options builder + */ + void customize(Consumer customization); + + /** Discards every recorded customization. */ + void resetCustomizations(); + + /** The running Compose stack, for steps that need a port the adoption does not use itself. */ + BackendEndpoint backendEndpoint(); + + /** Base URL of the flagd-testbed launchpad control API, without a trailing slash. */ + String launchpadUrl(); +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/tck/InProcessTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/tck/InProcessTest.java index 3dd2341506..105cd12c2c 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/tck/InProcessTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/tck/InProcessTest.java @@ -1,8 +1,12 @@ package dev.openfeature.contrib.providers.flagd.tck; import dev.openfeature.contrib.providers.flagd.Config; +import dev.openfeature.contrib.tools.tck.TckExcludeTags; +import dev.openfeature.contrib.tools.tck.TckIncludeTags; /** Runs the OpenFeature Provider TCK against the flagd provider in in-process mode. */ +@TckIncludeTags("@in-process") +@TckExcludeTags("@fractional-v2") public class InProcessTest extends AbstractResolverTest { /** @@ -16,7 +20,7 @@ public String configuration() { } @Override - protected Config.Resolver resolver() { + public Config.Resolver resolver() { return Config.Resolver.IN_PROCESS; } diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/tck/RpcTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/tck/RpcTest.java index 2a3ebe12ef..814b8eb75a 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/tck/RpcTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/tck/RpcTest.java @@ -1,8 +1,12 @@ package dev.openfeature.contrib.providers.flagd.tck; import dev.openfeature.contrib.providers.flagd.Config; +import dev.openfeature.contrib.tools.tck.TckExcludeTags; +import dev.openfeature.contrib.tools.tck.TckIncludeTags; /** Runs the OpenFeature Provider TCK against the flagd provider in RPC mode. */ +@TckIncludeTags("@rpc") +@TckExcludeTags("@fractional-v2") public class RpcTest extends AbstractResolverTest { /** @@ -18,7 +22,7 @@ public String configuration() { } @Override - protected Config.Resolver resolver() { + public Config.Resolver resolver() { return Config.Resolver.RPC; } diff --git a/providers/flagd/src/test/java/openfeature/tck/extensions/ConnectionSteps.java b/providers/flagd/src/test/java/openfeature/tck/extensions/ConnectionSteps.java new file mode 100644 index 0000000000..655033ef36 --- /dev/null +++ b/providers/flagd/src/test/java/openfeature/tck/extensions/ConnectionSteps.java @@ -0,0 +1,60 @@ +package openfeature.tck.extensions; + +import dev.openfeature.contrib.providers.flagd.tck.FlagdTckHarness; +import dev.openfeature.contrib.tools.tck.BackendEndpoint; +import dev.openfeature.contrib.tools.tck.ProviderTckHarness; +import io.cucumber.java.en.Given; + +/** + * Steps for the connection extension scenarios: pointing the provider at the envoy sidecar. + * + *

Event ordering used to live here too, because the TCK's await discarded the records ahead of + * its match and an {@code error} recorded before an awaited {@code ready} was gone by the time + * anything could object to it. The TCK keeps its own records now and asks the question itself, with + * {@code the {} event handler should have been executed with no {} event before it}. + */ +public class ConnectionSteps { + + /** A port nothing listens on, so a provider given a target URI cannot fall back to host and port. */ + private static final int UNAVAILABLE_PORT = 9999; + + private final FlagdTckHarness flagd; + + public ConnectionSteps(ProviderTckHarness harness) { + this.flagd = (FlagdTckHarness) harness; + } + + /** + * Connects the next provider through envoy, by a target URI whose {@code } is envoy's + * routing listener. The host and port options point at a closed port, as the testbed does, so + * the scenario passes only if the target URI is what the provider used. + * + * @param targetUri the testbed's target URI, with {@code localhost:} standing for envoy + */ + @Given("a target URI {string} routed through envoy") + public void aTargetUriRoutedThroughEnvoy(String targetUri) { + BackendEndpoint endpoint = flagd.backendEndpoint(); + String resolved = targetUri + .replace("localhost", endpoint.host(FlagdTckHarness.ENVOY_SERVICE)) + .replace( + "", + String.valueOf( + endpoint.port(FlagdTckHarness.ENVOY_SERVICE, FlagdTckHarness.ENVOY_ROUTING_PORT))); + flagd.customize(builder -> builder.targetUri(resolved).port(UNAVAILABLE_PORT)); + } + + /** + * Points the next provider at envoy's listener that answers every call with HTTP 403, which + * gRPC reports as {@code PERMISSION_DENIED}. + * + *

Followed by the TCK's {@code Given a unavailable provider}, the flavour that does not wait + * for readiness; a forbidden provider never becomes ready. + */ + @Given("the provider connects to envoy's forbidden listener") + public void theProviderConnectsToTheForbiddenListener() { + BackendEndpoint endpoint = flagd.backendEndpoint(); + String host = endpoint.host(FlagdTckHarness.ENVOY_SERVICE); + int port = endpoint.port(FlagdTckHarness.ENVOY_SERVICE, FlagdTckHarness.ENVOY_FORBIDDEN_PORT); + flagd.customize(builder -> builder.host(host).port(port)); + } +} diff --git a/providers/flagd/src/test/java/openfeature/tck/extensions/FlagdSteps.java b/providers/flagd/src/test/java/openfeature/tck/extensions/FlagdSteps.java new file mode 100644 index 0000000000..3eaab43fd9 --- /dev/null +++ b/providers/flagd/src/test/java/openfeature/tck/extensions/FlagdSteps.java @@ -0,0 +1,120 @@ +package openfeature.tck.extensions; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.openfeature.contrib.providers.flagd.FlagdOptions; +import dev.openfeature.contrib.providers.flagd.resolver.rpc.cache.CacheType; +import dev.openfeature.contrib.providers.flagd.tck.FlagdTckHarness; +import dev.openfeature.contrib.tools.tck.ProviderTckHarness; +import dev.openfeature.sdk.Value; +import io.cucumber.java.After; +import io.cucumber.java.en.Given; +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Shared steps for the flagd extension scenarios: the provider options the testbed's features set. + * + *

Everything else these features once needed from here is the TCK's now -- serving a named + * configuration, a timed outage, a context table and the negative event assertion were about + * nothing flagd-specific, and two of them were the control API's own operations behind a + * flagd-shaped step. + * + *

The features port flagd-testbed scenarios the TCK does not ask. They keep the testbed's + * wording wherever it does not collide with a TCK step. It collides on the provider: the TCK's + * {@code a {} provider} also matches {@code a stable flagd provider}, so a scenario records its + * options with {@code an option ...} and then uses the TCK's {@code Given a stable provider}. + */ +public class FlagdSteps { + + /** Option names the testbed spells differently from {@link FlagdOptions.FlagdOptionsBuilder}. */ + private static final Map OPTION_NAMES; + + static { + Map names = new HashMap<>(); + names.put("resolver", "resolverType"); + names.put("deadlineMs", "deadline"); + names.put("keepAliveTime", "keepAlive"); + names.put("cache", "cacheType"); + OPTION_NAMES = Collections.unmodifiableMap(names); + } + + private final FlagdTckHarness flagd; + + public FlagdSteps(ProviderTckHarness harness) { + this.flagd = (FlagdTckHarness) harness; + } + + /** + * Leaves no customization behind for a scenario that follows. Getting flagd off a scenario's own + * configuration is the TCK's business now: {@code BackendControl} records the one a scenario + * served and takes the next scenario back to this suite's. + */ + @After + public void restoreDefaults() { + flagd.resetCustomizations(); + } + + /** + * Sets an option on the next provider this scenario creates, by the testbed's option name. + * + * @param option the option name, as config.feature spells it + * @param type the value's type + * @param value the value + */ + @Given("an option {string} of type {string} with value {string}") + public void anOption(String option, String type, String value) throws IOException { + // The features give certPath relative to the testbed root, which this module checks out as + // the test-harness submodule. + Object converted = "certPath".equals(option) ? "test-harness/" + value : convert(value, type); + String name = OPTION_NAMES.getOrDefault(option, option); + Method setter = Arrays.stream(FlagdOptions.FlagdOptionsBuilder.class.getMethods()) + .filter(method -> method.getName().equals(name) && method.getParameterCount() == 1) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("FlagdOptions has no option '" + name + "'")); + flagd.customize(builder -> { + try { + setter.invoke(builder, converted); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Cannot set option '" + name + "' to " + converted, e); + } + }); + } + + /** The testbed's value conversion, for the types the extension features use. */ + static Object convert(String value, String type) throws IOException { + if ("null".equals(value) && !"String".equals(type)) { + return null; + } + switch (type) { + case "Boolean": + return Boolean.parseBoolean(value); + case "String": + return value; + case "Integer": + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return Long.parseLong(value); + } + case "Long": + return Long.parseLong(value); + case "Float": + return Double.parseDouble(value); + case "CacheType": + return CacheType.valueOf(value.toUpperCase()).getValue(); + case "StringList": + return value.isEmpty() + ? Collections.emptyList() + : Arrays.stream(value.split(",")).map(String::trim).collect(Collectors.toList()); + case "Object": + return Value.objectToValue(new ObjectMapper().readValue(value, Object.class)); + default: + throw new IllegalArgumentException("Unknown value type '" + type + "'"); + } + } +} diff --git a/providers/flagd/src/test/resources/junit-platform.properties b/providers/flagd/src/test/resources/junit-platform.properties deleted file mode 100644 index cc6bbf8f62..0000000000 --- a/providers/flagd/src/test/resources/junit-platform.properties +++ /dev/null @@ -1,21 +0,0 @@ -# Enable parallel scenario execution within each suite runner. -# Each scenario borrows its own ContainerEntry from ContainerPool, so -# concurrent scenarios are fully isolated — no shared flagd process. -cucumber.execution.parallel.enabled=true -# Dynamic strategy scales with available CPUs (factor=1.0 → 1 thread per core). -# ContainerPool caps at min(availableProcessors, 4) containers so Docker isn't -# overwhelmed; extra threads simply block waiting for a free container. -# Override pool size via -Dflagd.e2e.pool.size=N if needed. -cucumber.execution.parallel.config.strategy=dynamic -cucumber.execution.parallel.config.dynamic.factor=1 -# Scenarios tagged @env-var mutate System env vars globally, which every config -# scenario reads when building FlagdOptions defaults. A plain ENV_VARS lock only -# serialises @env-var scenarios against each other, so a concurrent default-reading -# scenario could still observe a mutated var. Acquire the JUnit global lock so -# @env-var scenarios run in full isolation (no other scenario runs concurrently). -cucumber.execution.exclusive-resources.env-var.read-write=org.junit.platform.engine.support.hierarchical.ExclusiveResource.GLOBAL_KEY -# Scenarios tagged @grace involve container restart + reconnection timing. -# Running two concurrent restarts under parallel load can push the -# reconnection past the 12-second EVENT_TIMEOUT_MS threshold. Serialise -# them so each restart has the full machine resources to itself. -cucumber.execution.exclusive-resources.grace.read-write=CONTAINER_RESTART diff --git a/providers/flagd/test-harness b/providers/flagd/test-harness index 9d8dace5df..c5ac5de573 160000 --- a/providers/flagd/test-harness +++ b/providers/flagd/test-harness @@ -1 +1 @@ -Subproject commit 9d8dace5df1acc0949cd20195c7b9bfc2156f4e3 +Subproject commit c5ac5de5730f79a294b9af186aa069e21291ed55