Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion tools/tck/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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<String> excluded = excluded(harness);
Set<String> included = included(harness);
requireAdopterTags("@TckExcludeTags", excluded);
requireAdopterTags("@TckIncludeTags", included);
List<String> both = new ArrayList<>(excluded);
both.retainAll(included);
if (!both.isEmpty()) {
throw new IllegalArgumentException(
harness.getName() + " both excludes and includes " + both + "; a tag is one or the other.");
}
}

/**
* Skips an extension scenario that carries an excluded tag, or none of the included tags when
* there are any. Canonical scenarios are never skipped here. Validates the annotations first,
* so no path to the gate can skip on a capability tag.
*
* @param source the scenario's feature file, as Cucumber reports it, or {@code null} if unknown
* @param tags the scenario's Gherkin tags, including the leading at-sign
* @param harness the harness class
* @throws IllegalArgumentException if the annotations are invalid; see {@link #requireValid}
* @throws TestAbortedException if the scenario is not selected
*/
public static void requireSelected(URI source, Collection<String> tags, Class<?> harness) {
requireValid(harness);
// before any skip, so an exclude cannot mask a reserved tag on the scenario
Capability.requireValidVocabulary(source, tags);
if (Capability.isCanonical(source)) {
return;
}
Set<String> excluded = excluded(harness);
List<String> carried = new ArrayList<>();
for (String tag : tags) {
if (excluded.contains(tag)) {
carried.add(tag);
}
}
if (!carried.isEmpty()) {
throw new TestAbortedException(
"Skipped: scenario carries " + carried + ", excluded by " + harness.getSimpleName() + ".");
}
Set<String> included = included(harness);
if (!included.isEmpty() && Collections.disjoint(tags, included)) {
throw new TestAbortedException(
"Skipped: scenario carries none of " + included + ", included by " + harness.getSimpleName() + ".");
}
}

static Set<String> excluded(Class<?> harness) {
return collect(harness, TckExcludeTags.class, TckExcludeTags::value);
}

static Set<String> included(Class<?> harness) {
return collect(harness, TckIncludeTags.class, TckIncludeTags::value);
}

// declarations on the class and its superclasses add up
private static <A extends Annotation> Set<String> collect(
Class<?> harness, Class<A> annotation, Function<A, String[]> value) {
Set<String> tags = new LinkedHashSet<>();
for (Class<?> type = harness; type != null; type = type.getSuperclass()) {
A declared = type.getDeclaredAnnotation(annotation);
if (declared != null) {
tags.addAll(Arrays.asList(value.apply(declared)));
}
}
return tags;
}

private static void requireAdopterTags(String source, Collection<String> tags) {
List<String> capabilityTags = new ArrayList<>();
for (String tag : tags) {
if (tag == null
|| tag.length() < 2
|| !tag.startsWith("@")
|| !tag.trim().equals(tag)) {
throw new IllegalArgumentException(
source + " names '" + tag + "'; a tag starts with '@' and has no surrounding whitespace.");
}
if (Capability.fromTag(tag).isPresent()) {
capabilityTags.add(tag);
}
}
if (!capabilityTags.isEmpty()) {
throw new IllegalArgumentException(source + " names TCK capability tags " + capabilityTags
+ "; gate them through capabilities() instead.");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,28 @@ default void reconnect() {
throw unsupported("reconnect");
}

/**
* Restarts the backend on one of its own named flag configurations.
*
* <p>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.
*
* <p>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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,12 +226,11 @@ static void requireDeclared(Collection<String> tags, Set<Capability> declared) {
* @throws TestAbortedException if a tag gates an inexpressible or an undeclared capability
*/
public static void requireDeclared(URI source, Collection<String> tags, Set<Capability> declared) {
requireNoExpiredReservation(tags);
requireKnownVocabulary(source, tags);
requireValidVocabulary(source, tags);
for (String tag : tags) {
Optional<Capability> found = fromTag(tag);
if (!found.isPresent()) {
// adopter tag; gates nothing
// adopter tag; gated by AdopterTags
continue;
}
Capability capability = found.get();
Expand All @@ -247,6 +246,23 @@ public static void requireDeclared(URI source, Collection<String> tags, Set<Capa
}
}

/**
* Fails on a tag the vocabulary refuses, before anything decides to skip the scenario.
*
* <p>Run ahead of {@link AdopterTags#requireSelected} as well, so deselecting a scenario cannot
* mask a reserved tag on it: a reservation that has expired is a maintenance signal about the
* suite, not a property of the run, and an adopter excluding some other tag on the same scenario
* should not silence it.
*
* @param source the scenario's feature file, as Cucumber reports it, or {@code null} if unknown
* @param tags the scenario's Gherkin tags, including the leading at-sign
* @throws IllegalStateException if a tag is reserved, or unknown on a canonical scenario
*/
static void requireValidVocabulary(URI source, Collection<String> tags) {
requireNoExpiredReservation(tags);
requireKnownVocabulary(source, tags);
}

/**
* Fails if a canonical (non-extension) scenario carries a tag unknown to {@link Capability}.
*
Expand All @@ -272,7 +288,7 @@ private static void requireKnownVocabulary(URI source, Collection<String> tags)
}

// canonical if the parent directory is FEATURES; null is not canonical
private static boolean isCanonical(URI source) {
static boolean isCanonical(URI source) {
if (source == null) {
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ final class HttpBackendControl implements BackendControl {

private static final Logger log = LoggerFactory.getLogger(HttpBackendControl.class);

private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30);

private final HttpClient http;
private final String baseUrl;
private final String backendConfiguration;
Expand All @@ -30,6 +32,9 @@ final class HttpBackendControl implements BackendControl {
// whether the backend was last left unreachable, so /reset alone won't suffice
private boolean backendStopped;

// a configuration one scenario asked for, which the next scenario has to be taken off again
private String scenarioConfiguration;

/**
* Creates a control client for a running backend. No post-command settle; control calls return
* once the backend has acted.
Expand Down Expand Up @@ -71,12 +76,18 @@ public BackendType backendType() {
/**
* {@inheritDoc}
*
* <p>Uses {@code POST /reset} if the backend is running, else {@code POST /start?config=...}.
* <p>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();
}
Expand Down Expand Up @@ -106,13 +117,30 @@ public void disconnect() {
/**
* {@inheritDoc}
*
* <p>Issues {@code POST /start?config=...}, which also restores the baseline flag state.
* <p>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}
*
* <p>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).
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -185,7 +217,7 @@ private void post(String path) {
private HttpResponse<Void> send(String path) {
HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + path))
.POST(HttpRequest.BodyPublishers.noBody())
.timeout(Duration.ofSeconds(30))
.timeout(REQUEST_TIMEOUT)
.build();
try {
return http.send(request, HttpResponse.BodyHandlers.discarding());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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;

/**
* Skips extension scenarios carrying any of these tags, even if they carry a
* {@link TckIncludeTags} tag. Canonical scenarios are exempt. Put it on the
* {@link ProviderTckHarness} class; declarations on the harness and its superclasses add up.
* Capability tags are refused.
*
* <pre>{@code
* @TckIncludeTags("@rpc")
* @TckExcludeTags({"@fractional-v1", "@fractional-v2"})
* public class RpcTest extends AbstractResolverTest { ... }
* }</pre>
*/
@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();
}
Loading
Loading