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
14 changes: 10 additions & 4 deletions tools/tck/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

<assertj.version>3.27.7</assertj.version>
<awaitility.version>4.3.0</awaitility.version>
<jackson-databind.version>2.22.1</jackson-databind.version>
<jackson-databind.version>2.22.3</jackson-databind.version>
<slf4j.version>2.0.17</slf4j.version>
<testcontainers.version>2.0.4</testcontainers.version>
<opentest4j.version>1.3.0</opentest4j.version>
Expand Down Expand Up @@ -66,10 +66,16 @@
<artifactId>cucumber-junit-platform-engine</artifactId>
</dependency>

<!-- injects TckState into step classes -->
<!--
The container TckObjectFactory builds, not io.cucumber:cucumber-picocontainer. That
artifact also registers Cucumber's own PicoFactory through META-INF/services, which
would put a second ObjectFactory on every adopter's classpath and make Cucumber refuse
to choose between them in the adopter's own suites.
-->
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-picocontainer</artifactId>
<groupId>org.picocontainer</groupId>
<artifactId>picocontainer</artifactId>
<version>2.15.2</version>
<scope>compile</scope>
</dependency>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,17 @@ private ComposeContainer startCompose() {
stack.withStartupTimeout(startupTimeout());

log.info("Starting Compose stack {} (started once per suite, never restarted)", composeFile.getAbsolutePath());
stack.start();
try {
stack.start();
} catch (RuntimeException e) {
// stopSuite() reaches the stack through a field this method has not returned into yet
try {
stack.stop();
} catch (RuntimeException cleanup) {
e.addSuppressed(cleanup);
}
throw e;
}
return stack;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand Down Expand Up @@ -145,7 +147,10 @@ public void awaitReady(Duration timeout) {

/** Starts the backend, seeding the baseline flag state. */
private void start() {
post("/start?config=" + backendConfiguration);
post(
backendConfiguration == null
? "/start"
: "/start?config=" + URLEncoder.encode(backendConfiguration, StandardCharsets.UTF_8));
backendStopped = false;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import java.util.Optional;
import java.util.ServiceLoader;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -31,6 +32,8 @@ final class TckLifecycle {

private static volatile boolean suiteRunning;

private static final AtomicReference<RuntimeException> startFailure = new AtomicReference<>();

private final ProviderTckHarness harness;
private final BackendControl backendControl;

Expand All @@ -44,12 +47,14 @@ static void beginSuite() {
ProviderTckHarness harness = discoverHarness();
log.info("Provider TCK harness: {}", harness.getClass().getName());
Capability.requireDeclarable(harness.capabilities());
startFailure.set(null);
suiteRunning = true;
}

/** Stops every lifecycle this suite started. */
static void endSuite() {
suiteRunning = false;
startFailure.set(null);
List<TckLifecycle> toStop;
synchronized (started) {
toStop = new ArrayList<>(started);
Expand Down Expand Up @@ -82,7 +87,21 @@ static Optional<TckLifecycle> startForExecutingSuite() {
if (!suiteRunning) {
return Optional.empty();
}
return Optional.of(start(discoverHarness()));
// Deliberately not synchronized: making a second thread wait for the first thread's
// Compose startup is the serialisation per-thread backends exist to avoid. Threads that race
// before a failure is cached each pay one attempt, in parallel; everyone after fails fast,
// and compareAndSet keeps the first failure rather than whichever landed last.
RuntimeException failed = startFailure.get();
if (failed != null) {
// one Compose timeout per suite, not one per scenario
throw new IllegalStateException("the suite's backend already failed to start", failed);
}
try {
return Optional.of(start(discoverHarness()));
} catch (RuntimeException e) {
startFailure.compareAndSet(null, e);
throw e;
}
}

/** Starts the given harness and registers the result for {@link #endSuite()}. */
Expand All @@ -99,8 +118,12 @@ static TckLifecycle start(ProviderTckHarness harness) {
started.add(lifecycle);
return lifecycle;
} catch (RuntimeException e) {
// release anything a partial start allocated, e.g. containers
harness.stopSuite();
// release anything a partial start allocated, e.g. containers, without losing why
try {
harness.stopSuite();
} catch (RuntimeException cleanup) {
e.addSuppressed(cleanup);
}
throw e;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,26 @@ void fallsBackToStartAndCachesTheAnswer() throws Exception {
}
}

@Test
@DisplayName("a null configuration posts /start with no query, rather than config=null")
void aNullConfigurationOmitsTheQuery() throws Exception {
try (StubControlApi stub = StubControlApi.start().answering("/reset", 404)) {
new HttpBackendControl(stub.baseUrl(), null).prepareScenario();

assertThat(stub.requests()).containsExactly("POST /reset", "POST /start");
}
}

@Test
@DisplayName("a configuration name is encoded, so a space cannot break the URI")
void theConfigurationNameIsEncoded() throws Exception {
try (StubControlApi stub = StubControlApi.start().answering("/reset", 404)) {
new HttpBackendControl(stub.baseUrl(), "two words").prepareScenario();

assertThat(stub.requests()).containsExactly("POST /reset", "POST /start?config=two+words");
}
}

@ParameterizedTest
@ValueSource(ints = {404, 501})
@DisplayName("both documented not-implemented statuses trigger the fallback")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,34 @@ public void stopSuite() {
.isZero();
}

@Test
@DisplayName("a teardown failure is attached to the startup failure, not substituted for it")
void cleanupFailureDoesNotReplaceTheCause() {
class FailsBothWays implements ProviderTckHarness {
@Override
public FeatureProvider createProvider() {
return new NoOpProvider();
}

@Override
public BackendControl backendControl() {
return null;
}

@Override
public void stopSuite() {
throw new IllegalStateException("teardown also failed");
}
}

assertThatThrownBy(() -> TckLifecycle.start(new FailsBothWays()))
.as("the adopter needs the reason the suite would not start")
.hasMessageContaining("backendControl() returned null")
.satisfies(thrown -> assertThat(thrown.getSuppressed())
.as("and the teardown failure is kept, not dropped")
.hasSize(1));
}

@Test
@DisplayName("no suite running means no lifecycle, so extension-only glue registers nothing")
void noSuiteMeansNoLifecycle() {
Expand Down
Loading