From f5a4d1c240ea908a4d974f8254b2a1213c5cc764 Mon Sep 17 00:00:00 2001 From: Hakan Sener Date: Tue, 17 Sep 2024 13:28:02 +0200 Subject: [PATCH] feat(#14): provide a @ScenarioScope annotation --- .../deployment/CucumberProcessor.java | 27 +++++- .../cucumber/it/StatefulSteps.java | 26 +++++ .../src/test/resources/simple.feature | 9 +- .../cucumber/CucumberQuarkusTest.java | 13 ++- .../quarkiverse/cucumber/ScenarioContext.java | 95 +++++++++++++++++++ .../quarkiverse/cucumber/ScenarioScope.java | 17 ++++ 6 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 integration-tests/src/test/java/io/quarkiverse/cucumber/it/StatefulSteps.java create mode 100644 runtime/src/main/java/io/quarkiverse/cucumber/ScenarioContext.java create mode 100644 runtime/src/main/java/io/quarkiverse/cucumber/ScenarioScope.java diff --git a/deployment/src/main/java/io/quarkiverse/cucumber/deployment/CucumberProcessor.java b/deployment/src/main/java/io/quarkiverse/cucumber/deployment/CucumberProcessor.java index 5dd968c..7c38e40 100644 --- a/deployment/src/main/java/io/quarkiverse/cucumber/deployment/CucumberProcessor.java +++ b/deployment/src/main/java/io/quarkiverse/cucumber/deployment/CucumberProcessor.java @@ -9,8 +9,12 @@ import io.cucumber.java.StepDefinitionAnnotation; import io.cucumber.java.StepDefinitionAnnotations; import io.quarkiverse.cucumber.CucumberQuarkusTest; +import io.quarkiverse.cucumber.ScenarioContext; +import io.quarkiverse.cucumber.ScenarioScope; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; -import io.quarkus.arc.processor.DotNames; +import io.quarkus.arc.deployment.ContextRegistrationPhaseBuildItem; +import io.quarkus.arc.deployment.ContextRegistrationPhaseBuildItem.ContextConfiguratorBuildItem; +import io.quarkus.arc.deployment.CustomScopeBuildItem; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.builditem.CombinedIndexBuildItem; import io.quarkus.deployment.builditem.FeatureBuildItem; @@ -53,7 +57,24 @@ AdditionalBeanBuildItem beanDefiningAnnotation(CombinedIndexBuildItem indexBuild .getAllKnownSubclasses(DotName.createSimple(CucumberQuarkusTest.class.getName()))) { stepClasses.add(i.name().toString()); } - return AdditionalBeanBuildItem.builder().addBeanClasses(stepClasses).setDefaultScope(DotNames.SINGLETON) - .setUnremovable().build(); + return AdditionalBeanBuildItem.builder() + .addBeanClasses(stepClasses) + .setDefaultScope(DotName.createSimple(ScenarioScope.class.getName())) + .setUnremovable() + .build(); + } + + @BuildStep + ContextConfiguratorBuildItem scenarioContext(ContextRegistrationPhaseBuildItem contextRegistrationPhase) { + return new ContextConfiguratorBuildItem( + contextRegistrationPhase.getContext() + .configure(ScenarioScope.class) + .normal() + .contextClass(ScenarioContext.class)); + } + + @BuildStep + CustomScopeBuildItem scenarioScope() { + return new CustomScopeBuildItem(DotName.createSimple(ScenarioScope.class.getName())); } } diff --git a/integration-tests/src/test/java/io/quarkiverse/cucumber/it/StatefulSteps.java b/integration-tests/src/test/java/io/quarkiverse/cucumber/it/StatefulSteps.java new file mode 100644 index 0000000..d4bb49f --- /dev/null +++ b/integration-tests/src/test/java/io/quarkiverse/cucumber/it/StatefulSteps.java @@ -0,0 +1,26 @@ +package io.quarkiverse.cucumber.it; + +import java.util.Objects; + +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; + +public class StatefulSteps { + + String state; + + @Given("the state {string}") + public void theState(String state) { + this.state = state; + } + + @Then("the state is not initialized") + public void theStateIsNotInitialized() { + assert state == null; + } + + @Then("the state is {string}") + public void theStateIs(String state) { + assert Objects.equals(this.state, state); + } +} diff --git a/integration-tests/src/test/resources/simple.feature b/integration-tests/src/test/resources/simple.feature index 756eeba..e66d506 100644 --- a/integration-tests/src/test/resources/simple.feature +++ b/integration-tests/src/test/resources/simple.feature @@ -2,4 +2,11 @@ Feature: Test feature Scenario: Test scenario Given I call the endpoint - Then the response is ok \ No newline at end of file + Then the response is ok + + Scenario: Test initialized stateful scenario + Given the state "ok" + Then the state is "ok" + + Scenario: Test non initialized stateful scenario + Then the state is not initialized diff --git a/runtime/src/main/java/io/quarkiverse/cucumber/CucumberQuarkusTest.java b/runtime/src/main/java/io/quarkiverse/cucumber/CucumberQuarkusTest.java index e0ed865..47d3a97 100644 --- a/runtime/src/main/java/io/quarkiverse/cucumber/CucumberQuarkusTest.java +++ b/runtime/src/main/java/io/quarkiverse/cucumber/CucumberQuarkusTest.java @@ -49,8 +49,10 @@ import io.cucumber.plugin.event.EventHandler; import io.cucumber.plugin.event.PickleStepTestStep; import io.cucumber.plugin.event.Status; +import io.cucumber.plugin.event.TestCaseFinished; import io.cucumber.plugin.event.TestStep; import io.cucumber.plugin.event.TestStepFinished; +import io.quarkus.arc.Arc; import io.quarkus.test.junit.QuarkusTest; @QuarkusTest @@ -112,6 +114,13 @@ List getTests() { Predicate filters = new Filters(runtimeOptions); + EventHandler scenarioFinishedHandler = __ -> { + var scenarioContext = Arc.container().getActiveContext(ScenarioScope.class); + if (scenarioContext != null) { + scenarioContext.destroy(); + } + }; + featureSupplier.get().forEach(f -> { List tests = new LinkedList<>(); tests.add(DynamicTest.dynamicTest("Start Feature", () -> context.beforeFeature(f))); @@ -126,9 +135,12 @@ List getTests() { resultAtomicReference.compareAndSet(null, event); } }; + + eventBus.registerHandlerFor(TestCaseFinished.class, scenarioFinishedHandler); eventBus.registerHandlerFor(TestStepFinished.class, handler); context.runTestCase(r -> r.runPickle(p)); eventBus.removeHandlerFor(TestStepFinished.class, handler); + eventBus.removeHandlerFor(TestCaseFinished.class, scenarioFinishedHandler); // if we have no main arguments, we are running as part of a junit test suite, we need to fail the junit test explicitly if (resultAtomicReference.get() != null) { @@ -155,7 +167,6 @@ List getTests() { features.add(DynamicContainer.dynamicContainer(f.getName().orElse(f.getSource()), tests.stream())); } }); - features.add(DynamicTest.dynamicTest("After All Features", context::runAfterAllHooks)); features.add(DynamicTest.dynamicTest("Finish Cucumber", context::finishTestRun)); diff --git a/runtime/src/main/java/io/quarkiverse/cucumber/ScenarioContext.java b/runtime/src/main/java/io/quarkiverse/cucumber/ScenarioContext.java new file mode 100644 index 0000000..67fc890 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/cucumber/ScenarioContext.java @@ -0,0 +1,95 @@ +package io.quarkiverse.cucumber; + +import java.lang.annotation.Annotation; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; + +import jakarta.enterprise.context.spi.Contextual; +import jakarta.enterprise.context.spi.CreationalContext; + +import io.quarkus.arc.ContextInstanceHandle; +import io.quarkus.arc.InjectableBean; +import io.quarkus.arc.InjectableContext; +import io.quarkus.arc.impl.ContextInstanceHandleImpl; + +public class ScenarioContext implements InjectableContext { + + private final ConcurrentMap, ContextInstanceHandle> instances = new ConcurrentHashMap<>(); + private final Lock beanLock = new ReentrantLock(); + + @Override + public void destroy() { + for (var contextInstanceHandle : instances.values()) { + contextInstanceHandle.destroy(); + } + instances.clear(); + } + + @Override + public void destroy(Contextual contextual) { + try (var contextInstanceHandle = instances.remove(contextual)) { + if (contextInstanceHandle != null) { + contextInstanceHandle.destroy(); + } + } + } + + @Override + public Class getScope() { + return ScenarioScope.class; + } + + @Override + @SuppressWarnings("unchecked") + public T get(Contextual contextual, CreationalContext creationalContext) { + var contextInstanceHandle = (ContextInstanceHandle) instances.get(contextual); + if (contextInstanceHandle != null) { + return contextInstanceHandle.get(); + } else if (creationalContext != null) { + beanLock.lock(); + try { + T createdInstance = contextual.create(creationalContext); + instances.put( + contextual, + new ContextInstanceHandleImpl<>( + (InjectableBean) contextual, + createdInstance, + creationalContext)); + return createdInstance; + } finally { + beanLock.unlock(); + } + } else { + return null; + } + } + + @Override + public T get(Contextual contextual) { + return get(contextual, null); + } + + @Override + public boolean isActive() { + return true; + } + + @Override + public ContextState getState() { + return new ScenarioContextState(instances); + } + + private record ScenarioContextState( + Map, ContextInstanceHandle> instances) implements ContextState { + + @Override + public Map, Object> getContextualInstances() { + return instances.values().stream() + .collect(Collectors.toMap(ContextInstanceHandle::getBean, ContextInstanceHandle::get)); + } + } +} diff --git a/runtime/src/main/java/io/quarkiverse/cucumber/ScenarioScope.java b/runtime/src/main/java/io/quarkiverse/cucumber/ScenarioScope.java new file mode 100644 index 0000000..c7eb6c7 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/cucumber/ScenarioScope.java @@ -0,0 +1,17 @@ +package io.quarkiverse.cucumber; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import jakarta.enterprise.context.NormalScope; + +@NormalScope +@Retention(RUNTIME) +@Target({ TYPE, METHOD, FIELD }) +public @interface ScenarioScope { +}