diff --git a/tools/tck/README.md b/tools/tck/README.md index 36fe7ee389..af301bb64a 100644 --- a/tools/tck/README.md +++ b/tools/tck/README.md @@ -246,11 +246,37 @@ src/test/java/openfeature/tck/extensions/FractionalSteps.java // package openf registered. - Step classes and step methods must be `public`; otherwise Cucumber reports scenarios as undefined. - Don't put files under `gherkin/`; a same-named file would silently shadow a canonical one. -- Unknown tags are tolerated in `extensions/` (they gate nothing) but fail the run in `gherkin/`. +- Unknown tags are tolerated in `extensions/` (select by them with the annotations below) but fail + the run in `gherkin/`. - `ProviderTck` exposes `EXTENSIONS`, `EXTENSION_GLUE` and `ALL_GLUE` for custom `@ConfigurationParameter`s; always use `ALL_GLUE` or canonical steps go undefined. - Portable scenarios belong in the TCK, not in an extension. +### Selecting extension scenarios by tag + +When one extension set serves several configurations, annotate each harness with the tags it runs +and the tags it doesn't: + +```java +@TckIncludeTags("@rpc") +@TckExcludeTags({"@fractional-v1", "@fractional-v2"}) +public class RpcTest extends AbstractResolverTest { ... } + +@TckIncludeTags("@in-process") +@TckExcludeTags("@fractional-v1") +public class InProcessTest extends AbstractResolverTest { ... } +``` + +- With `@TckIncludeTags`, an extension scenario runs only if it carries one of those tags; tag it + `@rpc @in-process` to run it in both. +- `@TckExcludeTags` skips an extension scenario carrying any of its tags, even an included one. +- A skipped scenario is reported with the tag that skipped it. Declarations on a harness and its + superclasses add up. +- Canonical scenarios are exempt. Capability tags are refused, at suite start and per scenario; + narrow `capabilities()` instead. +- `-Dcucumber.filter.tags` still works alongside them; a filtered scenario is absent from the report + rather than skipped. + ## Java notes - **Suite discovery** relies on the JUnit Platform auto-registering the TCK's `TestExecutionListener` diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/AdopterTags.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/AdopterTags.java new file mode 100644 index 0000000000..178e7f5980 --- /dev/null +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/AdopterTags.java @@ -0,0 +1,124 @@ +package dev.openfeature.contrib.tools.tck; + +import java.lang.annotation.Annotation; +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Function; +import org.opentest4j.TestAbortedException; + +/** + * The gate for {@link TckExcludeTags} and {@link TckIncludeTags} on a harness class. + * + *
Only adopter tags can be named: a {@link Capability} tag is refused, so this cannot skip
+ * what {@link ProviderTckHarness#capabilities()} gates. Canonical scenarios carry only capability
+ * tags, and are exempt besides.
+ */
+public final class AdopterTags {
+
+ private AdopterTags() {
+ // static gate; harnesses cannot override it
+ }
+
+ /**
+ * Rejects a capability tag, a malformed tag, or a tag both excluded and included.
+ *
+ * @param harness the harness class
+ * @throws IllegalArgumentException if the annotations name a capability tag or a malformed
+ * tag, or overlap
+ */
+ public static void requireValid(Class> harness) {
+ Set The names belong to the backend, not to the TCK: the control API takes {@code config} as a
+ * free string defaulting to {@code default}, so a feature file naming one is naming something
+ * only that backend's control defines. Optional for the reason {@link #disconnect()} is -- a
+ * control serving a single in-process flag set has no second configuration to offer -- and
+ * therefore for extension scenarios only. The canonical scenarios never ask: the baseline every
+ * control already serves is the configuration they are written against.
+ *
+ * Takes effect before the provider connects, so a scenario states it ahead of
+ * {@code a stable provider}, and {@link #reconnect()} brings this configuration back rather than
+ * the suite's -- losing the connection is not how a scenario changes what is served. The next
+ * scenario starts from this suite's own configuration again.
+ *
+ * @param configuration the configuration name, as that backend's control defines it
+ * @throws UnsupportedOperationException if this backend serves one configuration only
+ */
+ default void serveConfiguration(String configuration) {
+ throw unsupported("serveConfiguration");
+ }
+
/**
* Returns a short description of what is being controlled, for logs and failure messages.
*
diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java
index 8a85044f88..704aa7afeb 100644
--- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java
+++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java
@@ -226,12 +226,11 @@ static void requireDeclared(Collection Uses {@code POST /reset} if the backend is running, else {@code POST /start?config=...}.
+ * Uses {@code POST /reset} if the backend is running on this suite's own configuration, else
+ * {@code POST /start?config=...}: {@code /reset} restores flag state, not which configuration is
+ * being served, so a scenario that served another one has to be taken off it explicitly.
*/
@Override
public void prepareScenario() {
- if (backendStopped) {
- start();
+ if (backendStopped || scenarioConfiguration != null) {
+ // this suite's configuration, not the last scenario's: that is what preparing means
+ start(backendConfiguration);
+ // cleared only once the start succeeded, so a failure is retried next scenario rather
+ // than downgraded to /reset, which does not change which configuration is served
+ scenarioConfiguration = null;
} else {
reset();
}
@@ -106,13 +117,30 @@ public void disconnect() {
/**
* {@inheritDoc}
*
- * Issues {@code POST /start?config=...}, which also restores the baseline flag state.
+ * Issues {@code POST /start?config=...} on the configuration in effect -- the one a scenario
+ * served, if it served one -- because losing the connection is not how a scenario changes what
+ * the backend serves. It also restores that configuration's baseline flag state, which is why a
+ * scenario asserting a value across an outage asserts it from the provider's cache rather than
+ * from a mutation it made.
*/
@Override
public void reconnect() {
start();
}
+ /**
+ * {@inheritDoc}
+ *
+ * Issues {@code POST /start?config=...} with the name given, which also seeds that
+ * configuration's flag state.
+ */
+ @Override
+ public void serveConfiguration(String configuration) {
+ post("/start?config=" + URLEncoder.encode(configuration, StandardCharsets.UTF_8));
+ scenarioConfiguration = configuration;
+ backendStopped = false;
+ }
+
/**
* Waits until the control API accepts commands, probing optional {@code GET /healthz} (a
* {@code 404} counts as ready).
@@ -145,12 +173,16 @@ public void awaitReady(Duration timeout) {
throw new IllegalStateException("control API at " + baseUrl + " did not become ready within " + timeout, last);
}
- /** Starts the backend, seeding the baseline flag state. */
+ /** Starts the backend on the configuration in effect: the scenario's, else this suite's. */
private void start() {
+ start(scenarioConfiguration == null ? backendConfiguration : scenarioConfiguration);
+ }
+
+ private void start(String configuration) {
post(
- backendConfiguration == null
+ configuration == null
? "/start"
- : "/start?config=" + URLEncoder.encode(backendConfiguration, StandardCharsets.UTF_8));
+ : "/start?config=" + URLEncoder.encode(configuration, StandardCharsets.UTF_8));
backendStopped = false;
}
@@ -185,7 +217,7 @@ private void post(String path) {
private HttpResponse Differs from {@link #convert} in one place, and deliberately: an {@code Integer} too wide
+ * for 32 bits widens to a {@code long} instead of failing. A flag's default value is bounded by
+ * the accessor that reads it back -- {@code getIntegerDetails} cannot ask for more -- but nothing
+ * reads a context entry back through an accessor, and a targeting operator may hash a 64-bit
+ * number. Rejecting those would make the TCK refuse context a provider handles correctly.
+ *
+ * @param value the raw string from the feature file
+ * @param type the declared type, as {@link #convert} takes it
+ * @return the converted value
+ */
+ public static Object convertContextEntry(String value, String type) {
+ if ("Integer".equals(type) && !"null".equals(value)) {
+ try {
+ return Integer.parseInt(value);
+ } catch (NumberFormatException wider) {
+ return Long.parseLong(value);
+ }
+ }
+ return convert(value, type);
+ }
+
private static Value toValue(String json) {
try {
return Value.objectToValue(MAPPER.readValue(json, Object.class));
diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/internal/steps/ContextSteps.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/internal/steps/ContextSteps.java
index d4fb1b85a6..c93923ae20 100644
--- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/internal/steps/ContextSteps.java
+++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/internal/steps/ContextSteps.java
@@ -3,8 +3,14 @@
import dev.openfeature.contrib.tools.tck.BackendControl;
import dev.openfeature.contrib.tools.tck.ProviderTckHarness;
import dev.openfeature.contrib.tools.tck.TckState;
+import dev.openfeature.contrib.tools.tck.internal.TckValues;
+import dev.openfeature.sdk.MutableContext;
import dev.openfeature.sdk.MutableStructure;
+import dev.openfeature.sdk.Value;
+import io.cucumber.datatable.DataTable;
import io.cucumber.java.en.Given;
+import java.util.HashMap;
+import java.util.Map;
/**
* Steps that build the evaluation context passed to every evaluation.
@@ -42,6 +48,30 @@ public void contextContainingKeyWithTypeAndValue(String key, String type, String
}
}
+ /**
+ * Adds several typed entries to the evaluation context at once.
+ *
+ * The table form of the step above, for a scenario whose targeting depends on more than one
+ * key. Its type vocabulary is {@link TckValues}', which is wider than the single-key step's --
+ * {@code Long}, {@code StringList} and {@code Object} included -- because a table is where a
+ * scenario reaches for the types an operator takes.
+ *
+ * @param table rows with {@code key}, {@code type} and {@code value} columns
+ */
+ @Given("a context with the following keys:")
+ public void contextWithTheFollowingKeys(DataTable table) {
+ // Rebuilt rather than added to: MutableContext has typed add() overloads but none taking a
+ // Value, and the conversion above produces exactly that for an Object or a StringList. The
+ // map carries the targeting key, so a row may set it and the old one survives when none does.
+ Map This is how a scenario says "and no error": as an order, not as a window. "No
+ * error event fired" can only be answered for some stretch of time, and at any instant an event
+ * that never fires and one that fires a moment later are the same observation -- so a step
+ * asking it that way either slows every run down or passes because nothing has arrived yet.
+ * "No error before the provider came back ready" is a question about what was recorded, and the
+ * recording answers it with no clock involved.
+ *
+ * @param eventType the event kind to await
+ * @param forbiddenType the event kind that must not have been recorded before it
+ */
+ @Then("the {} event handler should have been executed with no {} event before it")
+ public void theEventHandlerShouldHaveBeenExecutedWithNoneBefore(String eventType, String forbiddenType) {
+ log.info("Awaiting {} event with no {} event before it", eventType, forbiddenType);
+ awaitRecorded(eventType, harness().eventTimeout().toMillis());
+
+ List Draining through the match would also discard records of other kinds, which a later
+ * assertion is entitled to see: an error event recorded before an awaited ready event would be
+ * gone, and the order assertion above would find nothing left to object to.
+ */
+ private void takeEvent(String eventType) {
+ ProviderEventRecord matched = state.events.stream()
+ .filter(event -> event.type().equals(eventType))
+ .findFirst()
+ .orElse(null);
+ if (matched != null) {
+ // removing only this record keeps a stale one of the same kind from a later await
+ state.events.remove(matched);
}
state.lastEvent = Optional.ofNullable(matched);
}
diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/internal/steps/FlagSteps.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/internal/steps/FlagSteps.java
index d7726727df..67b0ace46a 100644
--- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/internal/steps/FlagSteps.java
+++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/internal/steps/FlagSteps.java
@@ -1,6 +1,7 @@
package dev.openfeature.contrib.tools.tck.internal.steps;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.withinPercentage;
import dev.openfeature.contrib.tools.tck.BackendControl;
import dev.openfeature.contrib.tools.tck.FlagUnderTest;
@@ -9,6 +10,7 @@
import dev.openfeature.contrib.tools.tck.TckState;
import dev.openfeature.contrib.tools.tck.internal.TckValues;
import dev.openfeature.sdk.ErrorCode;
+import dev.openfeature.sdk.ImmutableMetadata;
import dev.openfeature.sdk.Structure;
import dev.openfeature.sdk.Value;
import io.cucumber.datatable.DataTable;
@@ -26,6 +28,9 @@ public class FlagSteps extends AbstractSteps {
private static final Logger log = LoggerFactory.getLogger(FlagSteps.class);
+ // float carries about seven significant digits, so this covers its widening and nothing else
+ private static final double FLOAT_TOLERANCE_PERCENT = 0.001;
+
public FlagSteps(TckState state, ProviderTckHarness harness, BackendControl backend) {
super(state, harness, backend);
}
@@ -222,6 +227,89 @@ public void theFlagShouldBePartOfTheEventPayload() {
assertThat(event.details().getFlagsChanged()).contains(state.flag.key());
}
+ /**
+ * Asserts typed flag-metadata entries on the last evaluation; an entry the table does not list
+ * is not checked.
+ *
+ * The wording and the columns are the specification's own, from
+ * {@code assets/gherkin/metadata.feature}: flag metadata is spec behaviour, not a vendor's, so
+ * the question belongs here rather than in each adoption's glue.
+ *
+ * @param table rows with {@code key}, {@code metadata_type} and {@code value} columns
+ */
+ @Then("the resolved metadata should contain")
+ public void theResolvedMetadataShouldContain(DataTable table) {
+ Map {@link ImmutableMetadata}'s getters are exact: {@code getDouble} answers null for a value
+ * stored through {@code addFloat}, and {@code getInteger} null for one stored as a long. Reading
+ * through them would fail a provider whose value is right and whose box is not, and a scenario
+ * asks about the value.
+ *
+ * {@code Float} is compared with a tolerance for the same reason -- a 32-bit float widened to
+ * a double is not the double the feature file's decimal parses to: {@code 0.1f} widens to
+ * 0.10000000149011612 while the table says 0.1.
+ *
+ * @param key the metadata key the row names
+ * @param type the declared metadata type
+ * @param value the expected value, as the feature file writes it
+ * @param metadata every entry the provider sent, for the failure message
+ */
+ private void assertMetadataValue(String key, String type, String value, Map One step rather than two, because the order was the whole difficulty: the backend has to be
+ * serving that configuration before the provider connects to it, and a scenario that said so in
+ * a separate {@code Given} could put it after {@code a stable provider} and watch the provider
+ * connect to the wrong flags. Here it cannot.
+ *
+ * The name is the backend's own -- see {@link BackendControl#serveConfiguration} -- so this is
+ * for extension scenarios. A canonical scenario names no configuration: the baseline every
+ * control serves is what it is written against.
+ *
+ * @param flavour either {@code stable} or {@code unavailable}
+ * @param configuration the configuration the backend should serve, as its control defines it
+ */
+ @Given("a {} provider with {string} configuration")
+ public void createProviderWithConfiguration(String flavour, String configuration) {
+ createProvider(flavour, configuration);
+ }
+
/**
* Asserts that the provider metadata name is not empty (requirement 2.1.1).
*/
diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/internal/steps/TckLifecycle.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/internal/steps/TckLifecycle.java
index 066a689cac..b3f094f80d 100644
--- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/internal/steps/TckLifecycle.java
+++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/internal/steps/TckLifecycle.java
@@ -1,5 +1,6 @@
package dev.openfeature.contrib.tools.tck.internal.steps;
+import dev.openfeature.contrib.tools.tck.AdopterTags;
import dev.openfeature.contrib.tools.tck.BackendControl;
import dev.openfeature.contrib.tools.tck.Capability;
import dev.openfeature.contrib.tools.tck.ProviderTckHarness;
@@ -47,6 +48,7 @@ static void beginSuite() {
ProviderTckHarness harness = discoverHarness();
log.info("Provider TCK harness: {}", harness.getClass().getName());
Capability.requireDeclarable(harness.capabilities());
+ AdopterTags.requireValid(harness.getClass());
startFailure.set(null);
suiteRunning = true;
}
diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ExcludedTagsSuiteFixture.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ExcludedTagsSuiteFixture.java
new file mode 100644
index 0000000000..fd6ad6f6ad
--- /dev/null
+++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ExcludedTagsSuiteFixture.java
@@ -0,0 +1,56 @@
+package dev.openfeature.contrib.tools.tck;
+
+import dev.openfeature.sdk.FeatureProvider;
+import io.cucumber.junit.platform.engine.Constants;
+import java.util.EnumSet;
+import java.util.Set;
+import org.junit.platform.suite.api.ConfigurationParameter;
+import org.junit.platform.suite.api.IncludeEngines;
+import org.junit.platform.suite.api.SelectClasspathResource;
+import org.junit.platform.suite.api.Suite;
+
+/**
+ * A real TCK suite over {@code skip-selftest/}, run by {@link ExcludedTagsTest}. Not named
+ * {@code *Test} so Surefire skips it; does not extend {@link ProviderTckTest} to avoid inheriting its selectors.
+ */
+@Suite
+@IncludeEngines("cucumber")
+@SelectClasspathResource(ExcludedTagsSuiteFixture.FEATURES)
+@ConfigurationParameter(key = Constants.PLUGIN_PROPERTY_NAME, value = ProviderTck.PLUGINS)
+@ConfigurationParameter(key = Constants.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, value = "false")
+@ConfigurationParameter(
+ key = Constants.EXECUTION_MODE_FEATURE_PROPERTY_NAME,
+ value = ProviderTck.FEATURE_EXECUTION_MODE)
+@ConfigurationParameter(key = Constants.GLUE_PROPERTY_NAME, value = ProviderTck.ALL_GLUE)
+@ConfigurationParameter(key = Constants.OBJECT_FACTORY_PROPERTY_NAME, value = ProviderTck.OBJECT_FACTORY)
+@TckExcludeTags(ExcludedTagsSuiteFixture.EXCLUDED)
+@TckIncludeTags(ExcludedTagsSuiteFixture.INCLUDED)
+public class ExcludedTagsSuiteFixture implements ProviderTckHarness {
+
+ /** The classpath directory holding this fixture's feature files. */
+ public static final String FEATURES = "skip-selftest";
+
+ /** The tag this fixture excludes. */
+ public static final String EXCLUDED = "@old-version";
+
+ /** The tag an extension scenario needs to run here. */
+ public static final String INCLUDED = "@mode-a";
+
+ private final InProcessBackendControl control = new InProcessBackendControl();
+
+ @Override
+ public BackendControl backendControl() {
+ return control;
+ }
+
+ @Override
+ public FeatureProvider createProvider() {
+ return control.createProvider();
+ }
+
+ /** Declares {@code @events}, so only the exclusion can skip the scenario carrying it. */
+ @Override
+ public Set The targeting key is the part worth pinning: the step rebuilds the context rather than adding
+ * to it, and rebuilding is where an earlier statement can be lost or a later one overridden.
+ */
+class ContextStepsTest {
+
+ /** A harness that brings nothing up. */
+ private static final class StubHarness implements ProviderTckHarness {
+
+ private final InProcessControl control = BackendControl.inProcess();
+
+ @Override
+ public FeatureProvider createProvider() {
+ return control.createProvider();
+ }
+
+ @Override
+ public BackendControl backendControl() {
+ return control;
+ }
+ }
+
+ private static ContextSteps stepsFor(TckState state) {
+ StubHarness harness = new StubHarness();
+ return new ContextSteps(state, harness, harness.backendControl());
+ }
+
+ /** A table carrying the {@code key | type | value} header the step reads. */
+ private static DataTable table(String... rows) {
+ List Both halves matter to a chain like stale, then error, then ready: each assertion has to see the
+ * records an earlier one did not claim, or an ordering question is answered from an emptied queue.
+ */
+class EventStepsTest {
+
+ private static final String READY = "ready";
+
+ private static final String ERROR = "error";
+
+ /** A harness that brings nothing up and answers a short event timeout. */
+ private static final class StubHarness implements ProviderTckHarness {
+
+ private final InProcessControl control = BackendControl.inProcess();
+
+ @Override
+ public FeatureProvider createProvider() {
+ return control.createProvider();
+ }
+
+ @Override
+ public BackendControl backendControl() {
+ return control;
+ }
+
+ @Override
+ public Duration eventTimeout() {
+ return Duration.ofMillis(300);
+ }
+ }
+
+ private static EventSteps stepsWith(TckState state) {
+ StubHarness harness = new StubHarness();
+ return new EventSteps(state, harness, harness.backendControl());
+ }
+
+ @Test
+ @DisplayName("an event of the forbidden kind recorded first fails the ordering")
+ void anEarlierForbiddenEventFailsTheOrdering() {
+ TckState state = new TckState();
+ state.events.add(new ProviderEventRecord(ERROR, null));
+ state.events.add(new ProviderEventRecord(READY, null));
+ EventSteps steps = stepsWith(state);
+
+ assertThatThrownBy(() -> steps.theEventHandlerShouldHaveBeenExecutedWithNoneBefore(READY, ERROR))
+ .as("the error was recorded before the ready, which is what the scenario forbids")
+ .hasMessageContaining("events recorded before the ready event");
+ }
+
+ @Test
+ @DisplayName("the same records in the other order pass, and the ready is consumed")
+ void theOrderingPassesWhenTheForbiddenEventComesAfter() {
+ TckState state = new TckState();
+ state.events.add(new ProviderEventRecord(READY, null));
+ state.events.add(new ProviderEventRecord(ERROR, null));
+ EventSteps steps = stepsWith(state);
+
+ assertThatCode(() -> steps.theEventHandlerShouldHaveBeenExecutedWithNoneBefore(READY, ERROR))
+ .doesNotThrowAnyException();
+
+ assertThat(state.lastEvent).get().extracting(ProviderEventRecord::type).isEqualTo(READY);
+ assertThat(state.events)
+ .as("the ready was claimed; the error is still there for whatever asks next")
+ .extracting(ProviderEventRecord::type)
+ .containsExactly(ERROR);
+ }
+
+ @Test
+ @DisplayName("an ordering question with nothing recorded at all times out rather than passing")
+ void nothingRecordedIsNotAPass() {
+ TckState state = new TckState();
+ EventSteps steps = stepsWith(state);
+
+ assertThatThrownBy(() -> steps.theEventHandlerShouldHaveBeenExecutedWithNoneBefore(READY, ERROR))
+ .as("it awaits the event it is about; absence is a failure, not a silent pass")
+ .hasMessageContaining("provider event ready");
+ }
+
+ @Test
+ @DisplayName("awaiting one kind of event does not throw away another kind's record")
+ void aPositiveAwaitDoesNotEraseTheEvidence() {
+ TckState state = new TckState();
+ state.events.add(new ProviderEventRecord(ERROR, null));
+ state.events.add(new ProviderEventRecord("change", null));
+ EventSteps steps = stepsWith(state);
+
+ steps.theEventHandlerShouldHaveBeenExecuted("change");
+
+ assertThat(state.events)
+ .as("the error arrived first; claiming the change must not make it unobservable")
+ .extracting(ProviderEventRecord::type)
+ .containsExactly(ERROR);
+ }
+
+ @Test
+ @DisplayName("a second await of the same kind waits for a newer record")
+ void anAwaitClaimsItsOwnRecord() {
+ TckState state = new TckState();
+ state.events.add(new ProviderEventRecord(READY, null));
+ EventSteps steps = stepsWith(state);
+
+ steps.theEventHandlerShouldHaveBeenExecuted(READY);
+
+ assertThatThrownBy(() -> steps.theEventHandlerShouldHaveBeenExecutedWithin(READY, 150))
+ .as("the one record was claimed, so a chain cannot satisfy two awaits with it")
+ .hasMessageContaining("provider event ready");
+ }
+}
diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/internal/steps/FlagMetadataStepsTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/internal/steps/FlagMetadataStepsTest.java
new file mode 100644
index 0000000000..35ca2e8ef7
--- /dev/null
+++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/internal/steps/FlagMetadataStepsTest.java
@@ -0,0 +1,193 @@
+package dev.openfeature.contrib.tools.tck.internal.steps;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import dev.openfeature.contrib.tools.tck.BackendControl;
+import dev.openfeature.contrib.tools.tck.InProcessControl;
+import dev.openfeature.contrib.tools.tck.ProviderTckHarness;
+import dev.openfeature.contrib.tools.tck.TckState;
+import dev.openfeature.sdk.FeatureProvider;
+import dev.openfeature.sdk.FlagEvaluationDetails;
+import dev.openfeature.sdk.ImmutableMetadata;
+import io.cucumber.datatable.DataTable;
+import io.cucumber.datatable.DataTableTypeRegistry;
+import io.cucumber.datatable.DataTableTypeRegistryTableConverter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The flag-metadata assertions, whose wording and columns are the specification's own.
+ *
+ * Each case is written so the opposite implementation would fail it: a value that matches and one
+ * that does not, metadata that is empty and metadata that is not.
+ */
+class FlagMetadataStepsTest {
+
+ /** A harness that brings nothing up. */
+ private static final class StubHarness implements ProviderTckHarness {
+
+ private final InProcessControl control = BackendControl.inProcess();
+
+ @Override
+ public FeatureProvider createProvider() {
+ return control.createProvider();
+ }
+
+ @Override
+ public BackendControl backendControl() {
+ return control;
+ }
+ }
+
+ private static FlagSteps stepsFor(TckState state) {
+ StubHarness harness = new StubHarness();
+ return new FlagSteps(state, harness, harness.backendControl());
+ }
+
+ private static TckState stateWith(ImmutableMetadata metadata) {
+ TckState state = new TckState();
+ state.evaluation = FlagEvaluationDetails.{@code
+ * @TckIncludeTags("@rpc")
+ * @TckExcludeTags({"@fractional-v1", "@fractional-v2"})
+ * public class RpcTest extends AbstractResolverTest { ... }
+ * }
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface TckExcludeTags {
+
+ /**
+ * Returns the tags to skip.
+ *
+ * @return the tags, with the leading at-sign
+ */
+ String[] value();
+}
diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/TckIncludeTags.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/TckIncludeTags.java
new file mode 100644
index 0000000000..93c67440a6
--- /dev/null
+++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/TckIncludeTags.java
@@ -0,0 +1,31 @@
+package dev.openfeature.contrib.tools.tck;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Runs only the extension scenarios carrying at least one of these tags; tag a scenario for
+ * several modes to run it in each. Canonical scenarios are exempt. Put it on the
+ * {@link ProviderTckHarness} class; declarations on the harness and its superclasses add up.
+ * Capability tags are refused.
+ *
+ * {@code
+ * @TckIncludeTags("@rpc")
+ * public class RpcTest extends AbstractResolverTest { ... } // runs "@rpc" and "@rpc @in-process"
+ * }
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface TckIncludeTags {
+
+ /**
+ * Returns the tags an extension scenario must carry one of.
+ *
+ * @return the tags, with the leading at-sign
+ */
+ String[] value();
+}
diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/internal/TckValues.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/internal/TckValues.java
index e9b6eb58be..2f721c1476 100644
--- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/internal/TckValues.java
+++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/internal/TckValues.java
@@ -3,6 +3,9 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.openfeature.sdk.Value;
import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.stream.Collectors;
/**
* Converts feature-file strings into typed values; {@code Integer} yields {@link Integer} and
@@ -20,7 +23,7 @@ private TckValues() {}
* @param value the raw string from the feature file; the literal {@code null} yields
* {@code null}
* @param type the declared type, one of {@code Boolean}, {@code String}, {@code Integer},
- * {@code Float} or {@code Object}
+ * {@code Long}, {@code Float}, {@code StringList} or {@code Object}
* @return the converted value
*/
public static Object convert(String value, String type) {
@@ -41,16 +44,48 @@ public static Object convert(String value, String type) {
+ "(Client.getIntegerDetails cannot request larger values).",
e);
}
+ case "Long":
+ return Long.parseLong(value);
case "Float":
return Double.parseDouble(value);
+ case "StringList":
+ // a context key an operator reads as a list; empty means the empty list, not [""]
+ return value.isEmpty()
+ ? Collections.emptyList()
+ : Arrays.stream(value.split(",")).map(String::trim).collect(Collectors.toList());
case "Object":
return toValue(value);
default:
throw new IllegalArgumentException("Unknown flag type '" + type
- + "'. Supported types are Boolean, String, Integer, Float and Object.");
+ + "'. Supported types are Boolean, String, Integer, Long, Float, StringList "
+ + "and Object.");
}
}
+ /**
+ * Converts a feature-file string for an evaluation context entry.
+ *
+ * > cells = new ArrayList<>();
+ cells.add(Arrays.asList("key", "type", "value"));
+ for (String row : rows) {
+ cells.add(Arrays.asList(row.split("\\s*\\|\\s*")));
+ }
+ // Cucumber's own converter: DataTable.create(cells) alone cannot answer asMaps, and the
+ // step reads the table the way a scenario hands it over.
+ return DataTable.create(
+ cells, new DataTableTypeRegistryTableConverter(new DataTableTypeRegistry(Locale.ENGLISH)));
+ }
+
+ @Test
+ @DisplayName("a targeting key set earlier survives a table that does not mention it")
+ void anEarlierTargetingKeySurvives() {
+ TckState state = new TckState();
+ ContextSteps steps = stepsFor(state);
+ steps.contextContainingTargetingKey("tim");
+
+ steps.contextWithTheFollowingKeys(table("region | String | eu"));
+
+ assertThat(state.context.getTargetingKey())
+ .as("the table said nothing about it, so it is still the targeting key")
+ .isEqualTo("tim");
+ assertThat(state.context.getValue("region").asString()).isEqualTo("eu");
+ }
+
+ @Test
+ @DisplayName("a table row may set the targeting key")
+ void theTableMaySetTheTargetingKey() {
+ TckState state = new TckState();
+ ContextSteps steps = stepsFor(state);
+ steps.contextContainingTargetingKey("tim");
+
+ steps.contextWithTheFollowingKeys(table("targetingKey | String | ada"));
+
+ assertThat(state.context.getTargetingKey())
+ .as("the row is the later statement about it, so it is the one that holds")
+ .isEqualTo("ada");
+ }
+
+ @Test
+ @DisplayName("a row's type decides what the context carries")
+ void theTableSetsTypedEntries() {
+ TckState state = new TckState();
+ ContextSteps steps = stepsFor(state);
+
+ steps.contextWithTheFollowingKeys(
+ table("region | String | eu", "editions | StringList | one, two", "fresh | Boolean | true"));
+
+ assertThat(state.context.getValue("region").asString()).isEqualTo("eu");
+ assertThat(state.context.getValue("editions").asList())
+ .as("a StringList is a list, not one comma-joined string")
+ .hasSize(2);
+ assertThat(state.context.getValue("fresh").asBoolean()).isTrue();
+ }
+
+ @Test
+ @DisplayName("entries from two tables add up")
+ void twoTablesAddUp() {
+ TckState state = new TckState();
+ ContextSteps steps = stepsFor(state);
+
+ steps.contextWithTheFollowingKeys(table("region | String | eu"));
+ steps.contextWithTheFollowingKeys(table("tier | String | gold"));
+
+ assertThat(state.context.getValue("region").asString()).isEqualTo("eu");
+ assertThat(state.context.getValue("tier").asString()).isEqualTo("gold");
+ }
+}
diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/internal/steps/EventStepsTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/internal/steps/EventStepsTest.java
new file mode 100644
index 0000000000..336f276f26
--- /dev/null
+++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/internal/steps/EventStepsTest.java
@@ -0,0 +1,126 @@
+package dev.openfeature.contrib.tools.tck.internal.steps;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import dev.openfeature.contrib.tools.tck.BackendControl;
+import dev.openfeature.contrib.tools.tck.InProcessControl;
+import dev.openfeature.contrib.tools.tck.ProviderEventRecord;
+import dev.openfeature.contrib.tools.tck.ProviderTckHarness;
+import dev.openfeature.contrib.tools.tck.TckState;
+import dev.openfeature.sdk.FeatureProvider;
+import java.time.Duration;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * What the event assertions read, and what they leave behind.
+ *
+ *
> cells = new ArrayList<>();
+ cells.add(Arrays.asList("key", "metadata_type", "value"));
+ for (String row : rows) {
+ cells.add(Arrays.asList(row.split("\\s*\\|\\s*")));
+ }
+ return DataTable.create(
+ cells, new DataTableTypeRegistryTableConverter(new DataTableTypeRegistry(Locale.ENGLISH)));
+ }
+
+ @Test
+ @DisplayName("every type the specification's table uses is compared by that type")
+ void typedEntriesAreCompared() {
+ TckState state = stateWith(ImmutableMetadata.builder()
+ .addString("string", "1.0.2")
+ .addInteger("integer", 2)
+ .addDouble("float", 0.1)
+ .addBoolean("boolean", true)
+ .build());
+
+ assertThatCode(() -> stepsFor(state)
+ .theResolvedMetadataShouldContain(table(
+ "string | String | 1.0.2",
+ "integer | Integer | 2",
+ "float | Float | 0.1",
+ "boolean | Boolean | true")))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("a value that differs fails, naming the key and what the provider sent")
+ void aWrongValueFails() {
+ TckState state =
+ stateWith(ImmutableMetadata.builder().addInteger("integer", 2).build());
+
+ assertThatThrownBy(() -> stepsFor(state).theResolvedMetadataShouldContain(table("integer | Integer | 3")))
+ .hasMessageContaining("metadata 'integer' (Integer)");
+ }
+
+ @Test
+ @DisplayName("an entry the table does not list is not checked")
+ void unlistedEntriesAreIgnored() {
+ TckState state = stateWith(ImmutableMetadata.builder()
+ .addString("listed", "yes")
+ .addString("unlisted", "whatever")
+ .build());
+
+ assertThatCode(() -> stepsFor(state).theResolvedMetadataShouldContain(table("listed | String | yes")))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("empty metadata satisfies the empty assertion, and anything else does not")
+ void theEmptyAssertion() {
+ assertThatCode(() ->
+ stepsFor(stateWith(ImmutableMetadata.builder().build())).theResolvedMetadataIsEmpty())
+ .doesNotThrowAnyException();
+
+ TckState withEntry =
+ stateWith(ImmutableMetadata.builder().addString("a", "b").build());
+ assertThatThrownBy(() -> stepsFor(withEntry).theResolvedMetadataIsEmpty())
+ .hasMessageContaining("flag metadata");
+ }
+
+ @Test
+ @DisplayName("a number is compared by value, whichever numeric type the provider stored it in")
+ void aNumberIsComparedByValueNotByBox() {
+ // ImmutableMetadata's getters are exact: getDouble answers null for an addFloat value, and
+ // getInteger null for a long, so reading through them failed a provider that was right.
+ TckState state = stateWith(ImmutableMetadata.builder()
+ .addFloat("float", 0.1f)
+ .addLong("integer", 2L)
+ .build());
+
+ assertThatCode(() -> stepsFor(state)
+ .theResolvedMetadataShouldContain(table("float | Float | 0.1", "integer | Integer | 2")))
+ .as("0.1f widens to 0.10000000149011612, and 2L is not an Integer")
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("a number that differs still fails, tolerance notwithstanding")
+ void aNumberThatDiffersStillFails() {
+ TckState state =
+ stateWith(ImmutableMetadata.builder().addFloat("float", 0.1f).build());
+
+ assertThatThrownBy(() -> stepsFor(state).theResolvedMetadataShouldContain(table("float | Float | 0.2")))
+ .hasMessageContaining("metadata 'float' (Float)");
+ }
+
+ @Test
+ @DisplayName("a row whose value is null does not pass against a key the provider omitted")
+ void anAbsentKeyIsNotANullValue() {
+ TckState state = stateWith(
+ ImmutableMetadata.builder().addString("present", "yes").build());
+
+ assertThatThrownBy(() -> stepsFor(state).theResolvedMetadataShouldContain(table("missing | String | null")))
+ .as("the conversion yields null and so does the lookup, which used to agree")
+ .hasMessageContaining("flag metadata");
+ }
+
+ @Test
+ @DisplayName("a numeric row against a value that is not a number says so")
+ void aNumericRowAgainstAStringSaysSo() {
+ TckState state =
+ stateWith(ImmutableMetadata.builder().addString("count", "2").build());
+
+ assertThatThrownBy(() -> stepsFor(state).theResolvedMetadataShouldContain(table("count | Integer | 2")))
+ .hasMessageContaining("declares numeric");
+ }
+
+ @Test
+ @DisplayName("a metadata type flag metadata cannot carry is refused, not silently compared")
+ void anUnknownTypeIsRefused() {
+ TckState state =
+ stateWith(ImmutableMetadata.builder().addString("a", "b").build());
+
+ assertThatThrownBy(() -> stepsFor(state).theResolvedMetadataShouldContain(table("a | Object | {}")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Flag metadata is String, Boolean, Integer or Float");
+ }
+
+ @Test
+ @DisplayName("asserting metadata before any evaluation says which step is missing")
+ void noEvaluationIsAClearFailure() {
+ TckState state = new TckState();
+
+ assertThatThrownBy(() -> stepsFor(state).theResolvedMetadataIsEmpty())
+ .hasMessageContaining("the flag was evaluated with details");
+ }
+}
diff --git a/tools/tck/src/test/resources/skip-selftest/extensions/adopter.feature b/tools/tck/src/test/resources/skip-selftest/extensions/adopter.feature
new file mode 100644
index 0000000000..e1c2a5321f
--- /dev/null
+++ b/tools/tck/src/test/resources/skip-selftest/extensions/adopter.feature
@@ -0,0 +1,27 @@
+Feature: An adopter selects its own extension scenarios by tag
+
+ Proof of TckIncludeTags and TckExcludeTags, run by ExcludedTagsTest. Kept outside the shared
+ extensions/ directory so the other suites never pick it up.
+
+ @mode-a
+ Scenario: An extension scenario carrying the included tag
+ Given a stable provider
+
+ @mode-a @mode-b
+ Scenario: An extension scenario tagged for both modes
+ Given a stable provider
+
+ @mode-b
+ Scenario: An extension scenario tagged for the other mode only
+ Given a stable provider
+
+ @mode-a @old-version @events
+ Scenario: An extension scenario carrying an excluded tag
+ Given a stable provider
+
+ @mode-b @filtered
+ Scenario: An extension scenario a tag filter removes first
+ Given a stable provider
+
+ Scenario: An extension scenario with no tags at all
+ Given a stable provider
diff --git a/tools/tck/src/test/resources/skip-selftest/gherkin/canonical.feature b/tools/tck/src/test/resources/skip-selftest/gherkin/canonical.feature
new file mode 100644
index 0000000000..45694af3a5
--- /dev/null
+++ b/tools/tck/src/test/resources/skip-selftest/gherkin/canonical.feature
@@ -0,0 +1,6 @@
+Feature: A canonical scenario runs whatever the harness includes or excludes
+
+ Proof for ExcludedTagsTest; canonical scenarios are exempt, and carry only capability tags.
+
+ Scenario: A canonical scenario runs without the included tag
+ Given a stable provider