From fc18ad2b1ca0c9d12a5fb4fd39167295bd5d7b2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Jourdan-Weil?= Date: Sun, 21 Apr 2024 17:58:44 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20implement=20scalatest=20int?= =?UTF-8?q?egration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.sbt | 34 +++- .../src/test/resources/cukes/cukes.feature | 89 +++++++++ .../src/test/scala/RunCukesTest.scala | 10 + .../scalatest/src/test/scala/StepDefs.scala | 185 ++++++++++++++++++ .../scala/TypeRegistryConfiguration.scala | 39 ++++ .../scalatest/src/test/scala/model/Cuke.scala | 3 + .../src/test/scala/model/Person.scala | 13 ++ .../src/test/scala/model/Snake.scala | 10 + .../options/CucumberSuiteOptionsParser.scala | 149 ++++++++++++++ .../io/cucumber/scalatest/CucumberSuite.scala | 147 ++++++++++++++ .../scalatest/CucumberSuiteOptions.scala | 131 +++++++++++++ .../io/cucumber/scalatest/FeatureSuite.scala | 79 ++++++++ .../scalatest/FilenameCompatibleNames.scala | 44 +++++ .../cucumber/scalatest/NoObjectFactory.scala | 17 ++ .../cucumber/scalatest/NoUuidGenerator.scala | 13 ++ .../io/cucumber/scalatest/PickleSuite.scala | 47 +++++ 16 files changed, 1006 insertions(+), 4 deletions(-) create mode 100644 integration-tests/scalatest/src/test/resources/cukes/cukes.feature create mode 100644 integration-tests/scalatest/src/test/scala/RunCukesTest.scala create mode 100644 integration-tests/scalatest/src/test/scala/StepDefs.scala create mode 100644 integration-tests/scalatest/src/test/scala/TypeRegistryConfiguration.scala create mode 100644 integration-tests/scalatest/src/test/scala/model/Cuke.scala create mode 100644 integration-tests/scalatest/src/test/scala/model/Person.scala create mode 100644 integration-tests/scalatest/src/test/scala/model/Snake.scala create mode 100644 scalatest/src/main/scala/io/cucumber/core/options/CucumberSuiteOptionsParser.scala create mode 100644 scalatest/src/main/scala/io/cucumber/scalatest/CucumberSuite.scala create mode 100644 scalatest/src/main/scala/io/cucumber/scalatest/CucumberSuiteOptions.scala create mode 100644 scalatest/src/main/scala/io/cucumber/scalatest/FeatureSuite.scala create mode 100644 scalatest/src/main/scala/io/cucumber/scalatest/FilenameCompatibleNames.scala create mode 100644 scalatest/src/main/scala/io/cucumber/scalatest/NoObjectFactory.scala create mode 100644 scalatest/src/main/scala/io/cucumber/scalatest/NoUuidGenerator.scala create mode 100644 scalatest/src/main/scala/io/cucumber/scalatest/PickleSuite.scala diff --git a/build.sbt b/build.sbt index b94ef6dc..1b77e483 100644 --- a/build.sbt +++ b/build.sbt @@ -42,6 +42,7 @@ val cucumberVersion = "7.17.0" val jacksonVersion = "2.17.0" val mockitoScalaVersion = "1.17.31" val junitVersion = "4.13.2" +val scalatestVersion = "3.2.18" // Projects and settings @@ -64,6 +65,7 @@ lazy val root = (project in file(".")) ) .aggregate( cucumberScala.projectRefs ++ + cucumberScalatest.projectRefs ++ integrationTestsCommon.projectRefs ++ integrationTestsJackson.projectRefs ++ integrationTestsPicoContainer.projectRefs ++ @@ -122,6 +124,17 @@ lazy val cucumberScala = (projectMatrix in file("cucumber-scala")) ) .jvmPlatform(scalaVersions = Seq(scala3, scala213, scala212)) +lazy val cucumberScalatest = (projectMatrix in file("scalatest")) + .settings(commonSettings) + .settings( + name := "cucumber-scalatest", + libraryDependencies ++= Seq( + "io.cucumber" % "cucumber-core" % cucumberVersion, + "org.scalatest" %% "scalatest" % scalatestVersion + ) + ) + .jvmPlatform(scalaVersions = Seq(scala3, scala213, scala212)) + // Integration tests lazy val integrationTestsCommon = (projectMatrix in file("integration-tests/common")) @@ -167,6 +180,19 @@ lazy val integrationTestsPicoContainer = .dependsOn(cucumberScala % Test) .jvmPlatform(scalaVersions = Seq(scala3, scala213, scala212)) +lazy val integrationTestsScalatest = + (projectMatrix in file("integration-tests/scalatest")) + .settings(commonSettings) + .settings( + name := "integration-tests-scalatest", + libraryDependencies ++= Seq( + "org.scalatest" %% "scalatest" % scalatestVersion % Test + ), + publishArtifact := false + ) + .dependsOn(cucumberScala % Test, cucumberScalatest % Test) + .jvmPlatform(scalaVersions = Seq(scala3, scala213, scala212)) + // Examples project lazy val examples = (projectMatrix in file("examples")) .settings(commonSettings) @@ -200,12 +226,12 @@ releaseProcess := Seq[ReleaseStep]( runTest, setReleaseVersion, // the 2 following steps are part of the Cucumber release process - //commitReleaseVersion, - //tagRelease, + // commitReleaseVersion, + // tagRelease, releaseStepCommandAndRemaining("publishSigned"), releaseStepCommand("sonatypeBundleRelease"), setNextVersion // the 2 following steps are part of the Cucumber release process - //commitNextVersion, - //pushChanges + // commitNextVersion, + // pushChanges ) diff --git a/integration-tests/scalatest/src/test/resources/cukes/cukes.feature b/integration-tests/scalatest/src/test/resources/cukes/cukes.feature new file mode 100644 index 00000000..79e23c91 --- /dev/null +++ b/integration-tests/scalatest/src/test/resources/cukes/cukes.feature @@ -0,0 +1,89 @@ +Feature: Cukes + + Scenario: in the belly + Given I have 4 "cukes" in my belly + Then I am "happy" + + Scenario: Int in the belly + Given I have eaten an int 100 + Then I should have one hundred in my belly + + Scenario: Long in the belly + Given I have eaten a long 100 + Then I should have long one hundred in my belly + + Scenario: String in the belly + Given I have eaten "numnumnum" + Then I should have numnumnum in my belly + + Scenario: Double in the belly + Given I have eaten 1.5 doubles + Then I should have one and a half doubles in my belly + + Scenario: Float in the belly + Given I have eaten 1.5 floats + Then I should have one and a half floats in my belly + + Scenario: Short in the belly + Given I have eaten a short 100 + Then I should have short one hundred in my belly + + Scenario: Byte in the belly + Given I have eaten a byte 2 + Then I should have two byte in my belly + + Scenario: BigDecimal in the belly + Given I have eaten 1.5 big decimals + Then I should have one and a half big decimals in my belly + + Scenario: BigInt in the belly + Given I have eaten 10 big int + Then I should have a ten big int in my belly + + Scenario: Char in the belly + Given I have eaten char 'C' + Then I should have character C in my belly + + Scenario: Boolean in the belly + Given I have eaten boolean true + Then I should have truth in my belly + + Scenario: DataTable in the belly + Given I have the following foods : + | FOOD | CALORIES | + | cheese | 500 | + | burger | 1000 | + | fries | 750 | + Then I am "definitely happy" + And have eaten 2250.0 calories today + + Scenario: DataTable with args in the belly + Given I have a table the sum of all rows should be 400 : + | ROW | + | 20 | + | 80 | + | 300 | + + Scenario: Argh! a snake - to be custom mapped + Given I see in the distance ... =====> + Then I have a snake of length 6 moving east + And I see in the distance ... <==================== + Then I have a snake of length 21 moving west + + Scenario: Custom object with string constructor + Given I have a person Bob + Then he should say "Hello, I'm Bob!" + + Scenario: Custom objects in the belly + Given I have eaten the following cukes + | Color | Number | + | Green | 1 | + | Red | 3 | + | Blue | 2 | + Then I should have eaten 6 cukes + And they should have been Green, Red, Blue + + Scenario: Did you know that we can handle call by name and zero arity + Given I drink gin and vermouth + When I shake my belly + Then I should have lots of martinis diff --git a/integration-tests/scalatest/src/test/scala/RunCukesTest.scala b/integration-tests/scalatest/src/test/scala/RunCukesTest.scala new file mode 100644 index 00000000..7663d63f --- /dev/null +++ b/integration-tests/scalatest/src/test/scala/RunCukesTest.scala @@ -0,0 +1,10 @@ +package cukes + +import io.cucumber.scalatest.{CucumberSuite, CucumberSuiteOptions} + +class RunCukesTest extends CucumberSuite with CucumberSuiteOptions { + + override def featuresPath: Seq[String] = Nil + + override def gluePackages: Seq[String] = Nil +} diff --git a/integration-tests/scalatest/src/test/scala/StepDefs.scala b/integration-tests/scalatest/src/test/scala/StepDefs.scala new file mode 100644 index 00000000..d95853cd --- /dev/null +++ b/integration-tests/scalatest/src/test/scala/StepDefs.scala @@ -0,0 +1,185 @@ +import io.cucumber.datatable.DataTable +import io.cucumber.scala.{EN, ScalaDsl} +import model.{Cukes, Person, Snake} +import org.junit.Assert.assertEquals + +import java.util.{List => JList, Map => JMap} +import scala.annotation.nowarn +import scala.jdk.CollectionConverters._ + +/** Test step definitions to exercise Scala cucumber + */ +@nowarn +class CukesStepDefinitions extends ScalaDsl with EN { + + var calorieCount = 0.0 + var intBelly: Int = 0 + var longBelly: Long = 0L + var stringBelly: String = "" + var doubleBelly: Double = 0.0 + var floatBelly: Float = 0.0f + var shortBelly: Short = 0.toShort + var byteBelly: Byte = 0.toByte + var bigDecimalBelly: BigDecimal = BigDecimal(0) + var bigIntBelly: BigInt = BigInt(0) + var charBelly: Char = 'A' + var boolBelly: Boolean = false + var snake: Snake = null + var person: Person = null + var cukes: JList[Cukes] = null + var gin: Int = 13 + var vermouth: Int = 42 + var maritinis: Int = 0 + + Given("""I have {} {string} in my belly""") { (howMany: Int, what: String) => + } + + Given("""^I have the following foods :$""") { (table: DataTable) => + val maps: JList[JMap[String, String]] = + table.asMaps(classOf[String], classOf[String]) + calorieCount = + maps.asScala.map(_.get("CALORIES")).map(_.toDouble).fold(0.0)(_ + _) + } + And("""have eaten {double} calories today""") { (calories: Double) => + assertEquals(calories, calorieCount, 0.0) + } + + Given("""I have eaten an int {int}""") { (arg0: Int) => + intBelly = arg0 + } + Then("""^I should have one hundred in my belly$""") { () => + assertEquals(100, intBelly) + } + + Given("""I have eaten a long {long}""") { (arg0: Long) => + longBelly = arg0 + } + Then("""^I should have long one hundred in my belly$""") { () => + assertEquals(100L, longBelly) + } + + Given("""^I have eaten "(.*)"$""") { (arg0: String) => + stringBelly = arg0 + } + Then("""^I should have numnumnum in my belly$""") { () => + assertEquals("numnumnum", stringBelly) + } + + Given("""I have eaten {double} doubles""") { (arg0: Double) => + doubleBelly = arg0 + } + Then("""^I should have one and a half doubles in my belly$""") { () => + assertEquals(1.5, doubleBelly, 0.0) + } + + Given("""I have eaten {} floats""") { (arg0: Float) => + floatBelly = arg0 + } + Then("""^I should have one and a half floats in my belly$""") { () => + assertEquals(1.5f, floatBelly, 0.0) + } + + Given("""I have eaten a short {short}""") { (arg0: Short) => + shortBelly = arg0 + } + Then("""^I should have short one hundred in my belly$""") { () => + assertEquals(100.toShort, shortBelly) + } + + Given("""I have eaten a byte {byte}""") { (arg0: Byte) => + byteBelly = arg0 + } + Then("""^I should have two byte in my belly$""") { () => + assertEquals(2.toByte, byteBelly) + } + + Given("""I have eaten {bigdecimal} big decimals""") { + (arg0: java.math.BigDecimal) => + bigDecimalBelly = arg0 + } + Then("""^I should have one and a half big decimals in my belly$""") { () => + assertEquals(BigDecimal(1.5), bigDecimalBelly) + } + + Given("""I have eaten {biginteger} big int""") { + (arg0: java.math.BigInteger) => + bigIntBelly = arg0.intValue() + } + Then("""^I should have a ten big int in my belly$""") { () => + assertEquals(BigInt(10), bigIntBelly) + } + + Given("""I have eaten char '{char}'""") { (arg0: Char) => + charBelly = 'C' + } + Then("""^I should have character C in my belly$""") { () => + assertEquals('C', charBelly) + } + + Given("""I have eaten boolean {boolean}""") { (arg0: Boolean) => + boolBelly = arg0 + } + Then("""^I should have truth in my belly$""") { () => + assertEquals(true, boolBelly) + } + + Given("""I have a table the sum of all rows should be {int} :""") { + (value: Int, table: DataTable) => + assertEquals( + value, + table + .asList(classOf[String]) + .asScala + .drop(1) + .map(String.valueOf(_: String).toInt) + .foldLeft(0)(_ + _) + ) + } + + Given("""I see in the distance ... {snake}""") { (s: Snake) => + snake = s + } + Then("""^I have a snake of length (\d+) moving (.*)$""") { + (size: Int, dir: String) => + assertEquals(size, snake.length) + assertEquals(Symbol(dir), snake.direction) + } + + Given("""I have a person {person}""") { (p: Person) => + person = p + } + + Then("""^he should say \"(.*)\"""") { (s: String) => + assertEquals(person.hello, s) + } + + Given("^I have eaten the following cukes$") { (cs: JList[Cukes]) => + cukes = cs + } + + Then("""I should have eaten {int} cukes""") { (total: Int) => + assertEquals(total, cukes.asScala.map(_.number).sum) + } + + And("^they should have been (.*)$") { (colors: String) => + assertEquals(colors, cukes.asScala.map(_.color).mkString(", ")) + } + + Given("^I drink gin and vermouth$") { () => + gin = 13 + vermouth = 42 + } + + When("^I shake my belly$") { // note the lack of () => + maritinis += vermouth * gin + } + + Then("^I should have lots of martinis$") { () => + assertEquals(13 * 42, maritinis) + } +} + +@nowarn +class ThenDefs extends ScalaDsl with EN { + Then("""^I am "([^"]*)"$""") { (arg0: String) => } +} diff --git a/integration-tests/scalatest/src/test/scala/TypeRegistryConfiguration.scala b/integration-tests/scalatest/src/test/scala/TypeRegistryConfiguration.scala new file mode 100644 index 00000000..4bfd146f --- /dev/null +++ b/integration-tests/scalatest/src/test/scala/TypeRegistryConfiguration.scala @@ -0,0 +1,39 @@ +import io.cucumber.scala.ScalaDsl +import model.{Cukes, Person, Snake} + +class TypeRegistryConfiguration extends ScalaDsl { + + /** Transforms an ASCII snake into an object, for example: + * + * {{{ + * ====> becomes Snake(length = 5, direction = 'east) + * ==> becomes Snake(length = 3, direction = 'east) + * }}} + */ + ParameterType("snake", "[=><]+") { s => + val size = s.length + val direction = s.toList match { + case '<' :: _ => Symbol("west") + case l if l.last == '>' => Symbol("east") + case _ => Symbol("unknown") + } + Snake(size, direction) + } + + ParameterType("person", ".+") { s => + Person(s) + } + + ParameterType("boolean", "true|false") { s => + s.trim.equals("true") + } + + ParameterType("char", ".") { s => + s.charAt(0) + } + + DataTableType { (map: Map[String, String]) => + Cukes(map("Number").toInt, map("Color")) + } + +} diff --git a/integration-tests/scalatest/src/test/scala/model/Cuke.scala b/integration-tests/scalatest/src/test/scala/model/Cuke.scala new file mode 100644 index 00000000..806027fb --- /dev/null +++ b/integration-tests/scalatest/src/test/scala/model/Cuke.scala @@ -0,0 +1,3 @@ +package model + +case class Cukes(number: Int, color: String) diff --git a/integration-tests/scalatest/src/test/scala/model/Person.scala b/integration-tests/scalatest/src/test/scala/model/Person.scala new file mode 100644 index 00000000..8007e698 --- /dev/null +++ b/integration-tests/scalatest/src/test/scala/model/Person.scala @@ -0,0 +1,13 @@ +package model + +/** Test model for a "Person" + * @param name + * of person + */ +case class Person(name: String) { + + def hello = { + "Hello, I'm " + name + "!" + } + +} diff --git a/integration-tests/scalatest/src/test/scala/model/Snake.scala b/integration-tests/scalatest/src/test/scala/model/Snake.scala new file mode 100644 index 00000000..cc7fc21e --- /dev/null +++ b/integration-tests/scalatest/src/test/scala/model/Snake.scala @@ -0,0 +1,10 @@ +package model + +/** Test model "Snake" to exercise the custom mapper functionality + * + * @param length + * of the snake in characters + * @param direction + * in which snake is moving 'west, 'east, etc + */ +case class Snake(length: Int, direction: Symbol) {} diff --git a/scalatest/src/main/scala/io/cucumber/core/options/CucumberSuiteOptionsParser.scala b/scalatest/src/main/scala/io/cucumber/core/options/CucumberSuiteOptionsParser.scala new file mode 100644 index 00000000..3bd4d278 --- /dev/null +++ b/scalatest/src/main/scala/io/cucumber/core/options/CucumberSuiteOptionsParser.scala @@ -0,0 +1,149 @@ +package io.cucumber.core.options + +import io.cucumber.core.exception.CucumberException +import io.cucumber.core.feature.{FeatureWithLines, GluePath} +import io.cucumber.core.resource.ClasspathSupport.CLASSPATH_SCHEME_PREFIX +import io.cucumber.core.snippets.SnippetType +import io.cucumber.scalatest.CucumberSuiteOptions +import io.cucumber.tagexpressions.{TagExpressionException, TagExpressionParser} + +import java.util.regex.Pattern +import scala.jdk.OptionConverters.RichOptional +import scala.util.{Failure, Success, Try} + +object CucumberSuiteOptionsParser { + + def unsafeParse[T <: CucumberSuiteOptions]( + options: T + ): RuntimeOptionsBuilder = { + parse(options) match { + case Success(value) => value + case Failure(exception) => + println( + "An exception happened while parsing CucumberSuite options. This is likely an issue with Cucumber-Scalatest implementation. Please open an issue on GitHub." + ) + exception.printStackTrace() + throw exception + } + } + + def parse[T <: CucumberSuiteOptions]( + options: T + ): Try[RuntimeOptionsBuilder] = { + Try { + // TODO verify how getClass behaves with the trait + val clazz = options.getClass + + println("aaa") + + val args = new RuntimeOptionsBuilder + + println("bbb") + + if (options.dryRun) { + args.setDryRun(true) + } + + println("bbb2") + + if (options.monochrome) { + args.setMonochrome(true) + } + + println("bbb3") + + val tagExpression = options.matchingTags + if (tagExpression.nonEmpty) { + Try { + args.addTagFilter(TagExpressionParser.parse(tagExpression)) + println("ok tags") + }.recover { case tee: TagExpressionException => + println("Invalid tag expression a") + throw new IllegalArgumentException( + String.format( + "Invalid tag expression at '%s'", + clazz.getName + ), + tee + ) + } + } + + println("ccc") + + options.plugins.foreach(args.addPluginName) + + if (options.publish) { + args.setPublish(true) + } + + options.names.foreach(name => args.addNameFilter(Pattern.compile(name))) + + val snippetType = options.snippets match { + case CucumberSuiteOptions.SnippetType.UNDERSCORE => + SnippetType.UNDERSCORE + case CucumberSuiteOptions.SnippetType.CAMELCASE => SnippetType.CAMELCASE + } + args.setSnippetType(snippetType) + + val hasExtraGlue = options.extraGluePackages.nonEmpty + val hasGlue = options.gluePackages.nonEmpty + + println("glue") + + if (hasExtraGlue && hasGlue) { + throw new CucumberException( + "gluePackages and extraGluePackages cannot be specified at the same time" + ) + } + + if (hasExtraGlue) { + options.extraGluePackages.foreach(glue => + args.addGlue(GluePath.parse(glue)) + ) + } + + if (hasGlue) { + options.gluePackages.foreach(glue => args.addGlue(GluePath.parse(glue))) + } else { + args.addGlue(GluePath.parse(packageName(clazz))) + } + + if (options.featuresPath.nonEmpty) { + options.featuresPath.foreach { feature => + val parsed = FeatureWithLinesOrRerunPath.parse(feature) + parsed.getFeaturesToRerun.toScala.foreach(features => + args.addRerun(features) + ) + parsed.getFeatureWithLines.toScala.foreach(features => + args.addFeature(features) + ) + } + } else { + val packageName = packagePath(clazz) + val featureWithLines = FeatureWithLines.parse(packageName) + args.addFeature(featureWithLines) + } + + // TODO +// args.setObjectFactoryClass(options.objectFactory) +// args.setUuidGeneratorClass(options.uuidGenerator) + + println("args") + + args + } + } + + private def packagePath(clazz: Class[_]): String = { + val name = packageName(clazz) + if (name.isEmpty) return CLASSPATH_SCHEME_PREFIX + "/" + CLASSPATH_SCHEME_PREFIX + "/" + name.replace('.', '/') + } + + private def packageName(clazz: Class[_]): String = { + val className = clazz.getName + className.substring(0, Math.max(0, className.lastIndexOf('.'))) + } + +} diff --git a/scalatest/src/main/scala/io/cucumber/scalatest/CucumberSuite.scala b/scalatest/src/main/scala/io/cucumber/scalatest/CucumberSuite.scala new file mode 100644 index 00000000..156edb28 --- /dev/null +++ b/scalatest/src/main/scala/io/cucumber/scalatest/CucumberSuite.scala @@ -0,0 +1,147 @@ +package io.cucumber.scalatest + +import io.cucumber.core.eventbus.EventBus +import io.cucumber.core.feature.FeatureParser +import io.cucumber.core.filter.Filters +import io.cucumber.core.gherkin.{Feature, Pickle} +import io.cucumber.core.options.{ + CucumberProperties, + CucumberPropertiesParser, + CucumberSuiteOptionsParser, + RuntimeOptions +} +import io.cucumber.core.plugin.{PluginFactory, Plugins} +import io.cucumber.core.resource.ClassLoaders +import io.cucumber.core.runtime.SynchronizedEventBus.synchronize +import io.cucumber.core.runtime._ +import org.scalatest._ + +import java.time.Clock +import java.util.function.{Predicate, Supplier} +import java.util.{Optional, UUID} +import scala.jdk.CollectionConverters._ +import scala.util.Try + +trait CucumberSuite extends Suite { this: CucumberSuiteOptions => + + private lazy val parsed = CucumberSuite.parse(this) + + override def nestedSuites: IndexedSeq[Suite] = parsed.children.toIndexedSeq + + override protected def runNestedSuites(args: Args): Status = { + println("runNestedSuites") + lazy val runChildren = { + println("runChildren") + super.runNestedSuites(args) + } + Try { + println("try") + parsed.context.runFeatures(() => { + println("runFeatures") + runChildren + () + }) + runChildren + } + .recover { case e: Throwable => + println("recover") + e.printStackTrace() + FailedStatus + } + .getOrElse(FailedStatus) + } +} + +object CucumberSuite { + + private def parse[T <: CucumberSuiteOptions]( + options: T + ): CucumberSuiteParsed = { + + println(s"parse $options") + + val propertiesFileOptions: RuntimeOptions = + new CucumberPropertiesParser() + .parse(CucumberProperties.fromPropertiesFile) + .build + + println("tata") + + val annotationOptions: RuntimeOptions = + CucumberSuiteOptionsParser + .unsafeParse(options) + .build(propertiesFileOptions) + + println("titi") + + val environmentOptions: RuntimeOptions = + new CucumberPropertiesParser() + .parse(CucumberProperties.fromEnvironment) + .build(annotationOptions) + + val runtimeOptions: RuntimeOptions = + new CucumberPropertiesParser() + .parse(CucumberProperties.fromSystemProperties) + .enablePublishPlugin + .build(environmentOptions) + + val bus: EventBus = synchronize( + new TimeServiceEventBus(Clock.systemUTC, () => UUID.randomUUID()) + ) + + println("toto") + + // Parse the features early. Don't proceed when there are lexer errors + val parser: FeatureParser = new FeatureParser(() => bus.generateId()) + val classLoader: Supplier[ClassLoader] = () => + ClassLoaders.getDefaultClassLoader + val featureSupplier: FeaturePathFeatureSupplier = + new FeaturePathFeatureSupplier(classLoader, runtimeOptions, parser) + val features: Seq[Feature] = featureSupplier.get.asScala.toSeq + + // Create plugins after feature parsing to avoid the creation of empty + // files on lexer errors. + val plugins = new Plugins(new PluginFactory, runtimeOptions) + val exitStatus = new ExitStatus(runtimeOptions) + plugins.addPlugin(exitStatus) + + val objectFactoryServiceLoader = + new ObjectFactoryServiceLoader(classLoader, runtimeOptions) + val objectFactorySupplier = new ThreadLocalObjectFactorySupplier( + objectFactoryServiceLoader + ) + val backendSupplier = new BackendServiceLoader( + () => options.getClass.getClassLoader, + objectFactorySupplier + ) + val runnerSupplier = new ThreadLocalRunnerSupplier( + runtimeOptions, + bus, + backendSupplier, + objectFactorySupplier + ) + val context = new CucumberExecutionContext(bus, exitStatus, runnerSupplier) + + val filters: Predicate[Pickle] = new Filters(runtimeOptions) + + val groupedByName: Map[Optional[String], Seq[Feature]] = + features.groupBy(_.getName) + val children = features + .map { feature => + val uniqueSuffix: Option[Int] = FilenameCompatibleNames + .uniqueSuffix(groupedByName, feature, (f: Feature) => f.getName) + FeatureSuite.createUnsafe(feature, uniqueSuffix, filters, context) + } + .filterNot(_.isEmpty) + + println(children) + + CucumberSuiteParsed(context, children) + } + + private case class CucumberSuiteParsed( + context: CucumberExecutionContext, + children: Seq[FeatureSuite] + ) + +} diff --git a/scalatest/src/main/scala/io/cucumber/scalatest/CucumberSuiteOptions.scala b/scalatest/src/main/scala/io/cucumber/scalatest/CucumberSuiteOptions.scala new file mode 100644 index 00000000..c49b7c8c --- /dev/null +++ b/scalatest/src/main/scala/io/cucumber/scalatest/CucumberSuiteOptions.scala @@ -0,0 +1,131 @@ +package io.cucumber.scalatest + +import io.cucumber.core.backend.ObjectFactory +import io.cucumber.core.eventbus.UuidGenerator +import io.cucumber.scalatest.CucumberSuiteOptions.SnippetType + +trait CucumberSuiteOptions { + + /** @return + * true if glue code execution should be skipped. + */ + def dryRun = false + + /** A list of features paths.

A feature path is constructed as + * {@code [ PATH[.feature[:LINE]*] | URI[.feature[:LINE]*] | @PATH ]}

+ * Examples:

When no feature path is + * provided, Cucumber will use the package of the annotated class. For + * example, if the annotated class is {@code com.example.RunCucumber} then + * features are assumed to be located in {@code classpath:com/example} . + * + * @return + * list of files or directories + * @see + * io.cucumber.core.feature.FeatureWithLines + */ + def featuresPath: Seq[String] = Nil + + /** Package to load glue code (step definitions, hooks and plugins) from. E.g: + * {@code com.example.app}

When no glue is provided, Cucumber will use + * the package of the annotated class. For example, if the annotated class is + * {@code com.example.RunCucumber} then glue is assumed to be located in + * {@code com.example} . + * + * @return + * list of package names + * @see + * io.cucumber.core.feature.GluePath + */ + def gluePackages: Seq[String] = Nil + + /** Package to load additional glue code (step definitions, hooks and plugins) + * from. E.g: {@code com.example.app}

These packages are used in addition + * to the default described in {@code #glue} . + * + * @return + * list of package names + */ + def extraGluePackages: Seq[String] = Nil + + /** Only run scenarios tagged with tags matching Tag Expression.

+ * For example {@code "@smoke and not @fast"} . + * + * @return + * a tag expression + */ + def matchingTags = "" + + /** Register plugins. Built-in plugin types: {@code junit} , {@code html} , + * {@code pretty} , {@code progress} , {@code json} , {@code usage} , + * {@code unused} , {@code rerun} , {@code testng} .

Can also be a fully + * qualified class name, allowing registration of 3rd party plugins.

+ * Plugins can be provided with an argument. For example + * {@code json:target/cucumber-report.json} + * + * @return + * list of plugins + * @see + * Plugin + */ + def plugins: Seq[String] = Nil + + /** Publish report to https://reports.cucumber.io.

+ * + * @return + * true if reports should be published on the web. + */ + def publish = false + + /** @return + * true if terminal output should be without colours. + */ + def monochrome: Boolean = false + + /** Only run scenarios whose names match one of the provided regular + * expressions. + * + * @return + * a list of regular expressions + */ + def names: Seq[String] = Nil + + /** @return + * the format of the generated snippets. + */ + def snippets: SnippetType = SnippetType.UNDERSCORE + + /** Specify a custom ObjectFactory.

In case a custom ObjectFactory is + * needed, the class can be specified here. A custom ObjectFactory might be + * needed when more granular control is needed over the dependency injection + * mechanism. + * + * @return + * an {@link io.cucumber.core.backend.ObjectFactory} implementation + */ + def objectFactory: Class[_ <: ObjectFactory] = classOf[NoObjectFactory] + + def uuidGenerator: Class[_ <: UuidGenerator] = classOf[NoUuidGenerator] + +} + +object CucumberSuiteOptions { + + sealed trait SnippetType + + object SnippetType { + case object UNDERSCORE extends SnippetType + case object CAMELCASE extends SnippetType + } + +} diff --git a/scalatest/src/main/scala/io/cucumber/scalatest/FeatureSuite.scala b/scalatest/src/main/scala/io/cucumber/scalatest/FeatureSuite.scala new file mode 100644 index 00000000..1cf6ea8a --- /dev/null +++ b/scalatest/src/main/scala/io/cucumber/scalatest/FeatureSuite.scala @@ -0,0 +1,79 @@ +package io.cucumber.scalatest + +import io.cucumber.core.exception.CucumberException +import io.cucumber.core.gherkin.{Feature, Pickle} +import io.cucumber.core.runtime.CucumberExecutionContext +import org.scalatest.{Args, Status, Suite} + +import java.util.function.Predicate +import scala.jdk.CollectionConverters.CollectionHasAsScala +import scala.jdk.OptionConverters.RichOptional +import scala.util.Try + +private[scalatest] final class FeatureSuite( + private val feature: Feature, + private val uniqueSuffix: Option[Int], + private val filter: Predicate[Pickle], + private val context: CucumberExecutionContext +) extends Suite { + + private val children: Seq[PickleSuite] = { + val groupedByName: Map[String, Seq[Pickle]] = + feature.getPickles.asScala.toSeq.groupBy(_.getName) + feature.getPickles.asScala.toSeq + .filter(p => filter.test(p)) + .map { (pickle: Pickle) => + val featureName = suiteName + val exampleId = FilenameCompatibleNames.uniqueSuffix( + groupedByName, + pickle, + (p: Pickle) => p.getName + ) + PickleSuite.withNoStepDescriptions( + featureName, + context, + pickle, + exampleId + ) + } + } + + override def suiteName: String = { + val name = feature.getName.toScala.getOrElse("EMPTY_NAME") + FilenameCompatibleNames.createName(name, uniqueSuffix, false) + } + + def isEmpty: Boolean = children.isEmpty + + override protected def runNestedSuites(args: Args): Status = { + context.beforeFeature(feature) + super.runNestedSuites(args) + } + + override def nestedSuites: IndexedSeq[Suite] = children.toIndexedSeq + +} + +object FeatureSuite { + + def createUnsafe( + feature: Feature, + uniqueSuffix: Option[Int], + filter: Predicate[Pickle], + context: CucumberExecutionContext + ): FeatureSuite = create(feature, uniqueSuffix, filter, context).get + + def create( + feature: Feature, + uniqueSuffix: Option[Int], + filter: Predicate[Pickle], + context: CucumberExecutionContext + ): Try[FeatureSuite] = { + Try { + new FeatureSuite(feature, uniqueSuffix, filter, context) + }.recover { case e: Throwable => + throw new CucumberException("Failed to create scenario runner", e) + } + } + +} diff --git a/scalatest/src/main/scala/io/cucumber/scalatest/FilenameCompatibleNames.scala b/scalatest/src/main/scala/io/cucumber/scalatest/FilenameCompatibleNames.scala new file mode 100644 index 00000000..7dd22ce7 --- /dev/null +++ b/scalatest/src/main/scala/io/cucumber/scalatest/FilenameCompatibleNames.scala @@ -0,0 +1,44 @@ +package io.cucumber.scalatest + +object FilenameCompatibleNames { + + private[scalatest] def createName( + name: String, + uniqueSuffix: Option[Int], + useFilenameCompatibleNames: Boolean + ): String = { + uniqueSuffix match { + case Some(suffix) => + createName(name + " #" + suffix + "", useFilenameCompatibleNames) + case None => createName(name, useFilenameCompatibleNames) + } + } + + private[scalatest] def createName( + name: String, + useFilenameCompatibleNames: Boolean + ): String = { + if (useFilenameCompatibleNames) { + makeNameFilenameCompatible(name) + } else { + name + } + } + + private def makeNameFilenameCompatible(name: String): String = { + name.replaceAll("[^A-Za-z0-9_]", "_") + } + + private[scalatest] def uniqueSuffix[V, K]( + groupedByName: Map[K, Seq[V]], + pickle: V, + nameOf: V => K + ): Option[Int] = { + val withSameName = groupedByName.get(nameOf.apply(pickle)) + withSameName match { + case Some(x) if x.size > 1 => Some(x.indexOf(pickle) + 1) + case _ => None + } + } + +} diff --git a/scalatest/src/main/scala/io/cucumber/scalatest/NoObjectFactory.scala b/scalatest/src/main/scala/io/cucumber/scalatest/NoObjectFactory.scala new file mode 100644 index 00000000..ec469faa --- /dev/null +++ b/scalatest/src/main/scala/io/cucumber/scalatest/NoObjectFactory.scala @@ -0,0 +1,17 @@ +package io.cucumber.scalatest + +import io.cucumber.core.backend.ObjectFactory + +/** This object factory does nothing. It is solely needed for marking purposes. + */ +final class NoObjectFactory extends ObjectFactory { + + override def addClass(glueClass: Class[_]): Boolean = false + + override def getInstance[T](glueClass: Class[T]): T = null.asInstanceOf[T] + + override def start(): Unit = {} + + override def stop(): Unit = {} + +} diff --git a/scalatest/src/main/scala/io/cucumber/scalatest/NoUuidGenerator.scala b/scalatest/src/main/scala/io/cucumber/scalatest/NoUuidGenerator.scala new file mode 100644 index 00000000..1fa68968 --- /dev/null +++ b/scalatest/src/main/scala/io/cucumber/scalatest/NoUuidGenerator.scala @@ -0,0 +1,13 @@ +package io.cucumber.scalatest + +import io.cucumber.core.eventbus.UuidGenerator + +import java.util.UUID + +/** This UUID generator does nothing. It is solely needed for marking purposes. + */ +final class NoUuidGenerator extends UuidGenerator { + + override def generateId: UUID = null + +} diff --git a/scalatest/src/main/scala/io/cucumber/scalatest/PickleSuite.scala b/scalatest/src/main/scala/io/cucumber/scalatest/PickleSuite.scala new file mode 100644 index 00000000..0fe6ee7b --- /dev/null +++ b/scalatest/src/main/scala/io/cucumber/scalatest/PickleSuite.scala @@ -0,0 +1,47 @@ +package io.cucumber.scalatest + +import io.cucumber.core.gherkin.Pickle +import io.cucumber.core.runner.Runner +import io.cucumber.core.runtime.CucumberExecutionContext +import org.scalatest._ + +import scala.util.Try + +private[scalatest] final class PickleSuite( + private val featureName: String, + private val context: CucumberExecutionContext, + private val pickle: Pickle, + private val uniqueSuffix: Option[Int] +) extends Suite { + + private val testName = + FilenameCompatibleNames.createName(pickle.getName, uniqueSuffix, false) + + override def suiteName: String = { + val className = FilenameCompatibleNames.createName(featureName, false) + s"$className $testName" + } + + override def testNames: Set[String] = Set(testName) + + override def runTest(testName: String, args: Args): Status = { + Try { + context.runTestCase { (runner: Runner) => + runner.runPickle(pickle) + } + SucceededStatus + }.getOrElse(FailedStatus) + } + +} + +object PickleSuite { + + def withNoStepDescriptions( + featureName: String, + context: CucumberExecutionContext, + pickle: Pickle, + uniqueSuffix: Option[Int] + ): PickleSuite = new PickleSuite(featureName, context, pickle, uniqueSuffix) + +}