diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..e5c98ef --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +open_collective: cucumber diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..1975114 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "github>cucumber/renovate-config" + ] +} diff --git a/.github/workflows/release-github.yml b/.github/workflows/release-github.yml new file mode 100644 index 0000000..88b128a --- /dev/null +++ b/.github/workflows/release-github.yml @@ -0,0 +1,18 @@ +name: Release GitHub + +on: + push: + branches: [release/*] + +jobs: + create-github-release: + name: Create GitHub Release and Git tag + runs-on: ubuntu-latest + environment: Release + permissions: + contents: write + steps: + - uses: actions/checkout@v3 + - uses: cucumber/action-create-github-release@v1.1.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-java.yml b/.github/workflows/release-java.yml new file mode 100644 index 0000000..1e3f9f8 --- /dev/null +++ b/.github/workflows/release-java.yml @@ -0,0 +1,24 @@ +name: Release Maven + +on: + push: + branches: [release/*] + +jobs: + publish-mvn: + name: Publish Maven Package + runs-on: ubuntu-latest + environment: Release + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-java@v3 + with: + distribution: 'zulu' + java-version: '17' + cache: 'maven' + - uses: cucumber/action-publish-mvn@v2.0.0 + with: + gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} + gpg-passphrase: ${{ secrets.GPG_PASSPHRASE }} + nexus-username: ${{ secrets.SONATYPE_USERNAME }} + nexus-password: ${{ secrets.SONATYPE_PASSWORD }} diff --git a/.github/workflows/test-java.yml b/.github/workflows/test-java.yml new file mode 100644 index 0000000..d71ba99 --- /dev/null +++ b/.github/workflows/test-java.yml @@ -0,0 +1,33 @@ +name: Test Java + +on: + pull_request: + branches: + - '**' + workflow_call: + push: + branches: + - main + - renovate/** + +jobs: + build: + strategy: + matrix: + os: [ ubuntu-latest, windows-latest ] + version: [ 17, 19 ] + name: 'Build Java ${{ matrix.version }} - ${{ matrix.os }}' + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-java@v3 + with: + distribution: 'zulu' + java-version: ${{ matrix.version }} + cache: 'maven' + - name: Install dependencies + run: mvn install -DskipTests=true -DskipITs=true --batch-mode -D"style.color=always" --show-version + - name: Test + run: mvn verify -D"style.color=always" + env: + CUCUMBER_PUBLISH_TOKEN: ${{ secrets.CUCUMBER_PUBLISH_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2fec83d --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# IDE working files +*.iws +*.ipr +*.iml +.idea/ +.settings +.project +.classpath +lib/ + + +# Build directories +distrib/ +target/ +tmp/ +gen-external-apklibs/ +out/ + +# Build & test droppings +pom.xml.releaseBackup +pom.xml.versionsBackup +release.propertiesF +*.ser +dependency-reduced-pom.xml +*~ +libpeerconnection.log + +# OS generated files +.DS_Store* +ehthumbs.db +Icon? +Thumbs.db diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..0182d96 --- /dev/null +++ b/LICENCE @@ -0,0 +1,20 @@ +Copyright (c) The Cucumber Organisation + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 4864c38..0c7e4d6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,48 @@ # Cucumber JVM migration Cucumber-JVM migration contains [OpenRewrite](https://docs.openrewrite.org/) recipes for migrating applications using Cucumber-JVM. + +## Running migration recipes +Migration recipes can be run using the [rewrite-maven-plugin](https://docs.openrewrite.org/reference/rewrite-maven-plugin) +or [rewrite-gradle-plugin](https://docs.openrewrite.org/reference/gradle-plugin-configuration). +These can either be added to the build file of the project to be migrated or [run without modifying the build](https://docs.openrewrite.org/running-recipes/running-rewrite-on-a-maven-project-without-modifying-the-build). + + +### Upgrade to Cucumber JVM 7.x +```shell +mvn -U org.openrewrite.maven:rewrite-maven-plugin:run \ + -Drewrite.recipeArtifactCoordinates=io.cucumber:cucumber-jvm-migration:LATEST \ + -DactiveRecipes=io.cucumber.migration.UpgradeCucumber7x +``` + +### Cucumber-Java8 migration to Cucumber-Java +```shell +mvn -U org.openrewrite.maven:rewrite-maven-plugin:run \ + -Drewrite.recipeArtifactCoordinates=io.cucumber:cucumber-jvm-migration:LATEST \ + -DactiveRecipes=io.cucumber.migration.CucumberJava8ToJava +``` + + +## Questions, Problems, Help needed? + +Please ask on + +* [Stack Overflow](https://stackoverflow.com/questions/tagged/cucumber-jvm). +* [CucumberBDD Slack](https://cucumberbdd-slack-invite.herokuapp.com/) [direct link](https://cucumberbdd.slack.com/) + +## Bugs and Feature requests + +You can register bugs and feature requests in the +[GitHub Issue Tracker](https://github.com/cucumber/cucumber-jvm-migration/issues). + +Please bear in mind that this project is almost entirely developed by +volunteers. If you do not provide the implementation yourself (or pay someone +to do it for you), the bug might never get fixed. If it is a serious bug, other +people than you might care enough to provide a fix. + +## Contributing + +If you'd like to contribute to the documentation, checkout +[cucumber/docs.cucumber.io](https://github.com/cucumber/docs.cucumber.io) +otherwise see our +[CONTRIBUTING.md](https://github.com/cucumber/cucumber-jvm/blob/main/CONTRIBUTING.md). diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..22b6850 --- /dev/null +++ b/pom.xml @@ -0,0 +1,179 @@ + + + 4.0.0 + + + io.cucumber + cucumber-parent + 4.1.1 + + + + cucumber-jvm-migration + 0.1.0-SNAPSHOT + + Cucumber JVM Migration + Migration module to upgrade projects using cucumber-jvm. + + + 1.8 + 7.11.0 + 5.9.2 + 1.17.0 + UTF-8 + 1674814830 + io.cucumber.migration + + + scm:git:git://github.com/cucumber/cucumber-jvm-migration.git + scm:git:git@github.com:cucumber/cucumber-jvm-migration.git + git://github.com/cucumber/cucumber-jvm-migration.git + HEAD + + + + + + io.cucumber + cucumber-bom + ${cucumber.version} + pom + import + + + org.junit + junit-bom + ${junit.version} + pom + import + + + org.openrewrite.recipe + rewrite-recipe-bom + ${rewrite.version} + pom + import + + + + + + + io.cucumber + cucumber-java + + + io.cucumber + cucumber-java8 + + + io.cucumber + cucumber-plugin + + + io.cucumber + cucumber-junit-platform-engine + + + org.junit.platform + junit-platform-suite-api + + + + org.openrewrite + rewrite-java + + + org.openrewrite + rewrite-gradle + + + org.openrewrite + rewrite-maven + + + + org.openrewrite + rewrite-java-17 + test + + + org.openrewrite + rewrite-test + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + + org.projectlombok + lombok + 1.18.24 + provided + + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + enforce-maven-3-6-3-plus + + enforce + + + true + + + 3.6.3 + + + + + + enforce-java + + enforce + + + + + [11,) + + + + + + + + + org.apache.maven.plugins + maven-resources-plugin + + ${resources.encoding} + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${base.java.version} + ${base.java.version} + + 17 + 17 + + + + + + \ No newline at end of file diff --git a/src/main/java/io/cucumber/migration/CucumberAnnotationToSuite.java b/src/main/java/io/cucumber/migration/CucumberAnnotationToSuite.java new file mode 100644 index 0000000..d33a919 --- /dev/null +++ b/src/main/java/io/cucumber/migration/CucumberAnnotationToSuite.java @@ -0,0 +1,101 @@ +package io.cucumber.migration; + +import lombok.SneakyThrows; +import org.openrewrite.ExecutionContext; +import org.openrewrite.Recipe; +import org.openrewrite.TreeVisitor; +import org.openrewrite.internal.ListUtils; +import org.openrewrite.internal.lang.Nullable; +import org.openrewrite.java.AnnotationMatcher; +import org.openrewrite.java.JavaIsoVisitor; +import org.openrewrite.java.JavaParser; +import org.openrewrite.java.JavaTemplate; +import org.openrewrite.java.search.UsesType; +import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.J.ClassDeclaration; +import org.openrewrite.java.tree.JavaType; +import org.openrewrite.java.tree.TypeUtils; + +import java.text.RuleBasedCollator; +import java.time.Duration; +import java.util.Comparator; +import java.util.function.Supplier; + +public class CucumberAnnotationToSuite extends Recipe { + + private static final String IO_CUCUMBER_JUNIT_PLATFORM_ENGINE_CUCUMBER = "io.cucumber.junit.platform.engine.Cucumber"; + + private static final String SUITE = "org.junit.platform.suite.api.Suite"; + private static final String SELECT_CLASSPATH_RESOURCE = "org.junit.platform.suite.api.SelectClasspathResource"; + + @Override + public String getDisplayName() { + return "Replace @Cucumber with @Suite"; + } + + @Override + public String getDescription() { + return "Replace @Cucumber with @Suite and @SelectClasspathResource(\"cucumber/annotated/class/package\")."; + } + + @Override + public @Nullable Duration getEstimatedEffortPerOccurrence() { + return Duration.ofMinutes(2); + } + + @Override + protected TreeVisitor getSingleSourceApplicableTest() { + return new UsesType<>(IO_CUCUMBER_JUNIT_PLATFORM_ENGINE_CUCUMBER); + } + + @Override + protected JavaIsoVisitor getVisitor() { + final AnnotationMatcher cucumberAnnoMatcher = new AnnotationMatcher( + "@" + IO_CUCUMBER_JUNIT_PLATFORM_ENGINE_CUCUMBER); + + return new JavaIsoVisitor() { + @SneakyThrows + @Override + public J.ClassDeclaration visitClassDeclaration(ClassDeclaration cd, ExecutionContext ctx) { + ClassDeclaration classDecl = super.visitClassDeclaration(cd, ctx); + if (classDecl.getAllAnnotations().stream().noneMatch(cucumberAnnoMatcher::matches)) { + return classDecl; + } + + Supplier javaParserSupplier = () -> JavaParser.fromJavaVersion() + .classpath("junit-platform-suite-api") + .build(); + + JavaType.FullyQualified classFqn = TypeUtils.asFullyQualified(classDecl.getType()); + if (classFqn != null) { + maybeRemoveImport(IO_CUCUMBER_JUNIT_PLATFORM_ENGINE_CUCUMBER); + maybeAddImport(SUITE); + maybeAddImport(SELECT_CLASSPATH_RESOURCE); + + final String classDeclPath = classFqn.getPackageName().replace('.', '/'); + classDecl = classDecl + .withLeadingAnnotations(ListUtils.map(classDecl.getLeadingAnnotations(), ann -> { + if (cucumberAnnoMatcher.matches(ann)) { + String code = "@SelectClasspathResource(\"#{}\")"; + JavaTemplate template = JavaTemplate.builder(this::getCursor, code) + .javaParser(javaParserSupplier) + .imports(SELECT_CLASSPATH_RESOURCE) + .build(); + return ann.withTemplate(template, ann.getCoordinates().replace(), classDeclPath); + } + return ann; + })); + classDecl = classDecl.withTemplate(JavaTemplate.builder(this::getCursor, "@Suite") + .javaParser(javaParserSupplier) + .imports(SUITE) + .build(), + classDecl.getCoordinates().addAnnotation(Comparator.comparing( + J.Annotation::getSimpleName, + new RuleBasedCollator("< SelectClasspathResource")))); + } + return classDecl; + } + }; + } + +} diff --git a/src/main/java/io/cucumber/migration/CucumberJava8ClassVisitor.java b/src/main/java/io/cucumber/migration/CucumberJava8ClassVisitor.java new file mode 100644 index 0000000..cc0b05c --- /dev/null +++ b/src/main/java/io/cucumber/migration/CucumberJava8ClassVisitor.java @@ -0,0 +1,121 @@ +package io.cucumber.migration; + +import lombok.RequiredArgsConstructor; +import org.openrewrite.ExecutionContext; +import org.openrewrite.java.JavaIsoVisitor; +import org.openrewrite.java.JavaParser; +import org.openrewrite.java.JavaTemplate; +import org.openrewrite.java.tree.*; +import org.openrewrite.java.tree.JavaType.FullyQualified; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +class CucumberJava8ClassVisitor extends JavaIsoVisitor { + + private static final String IO_CUCUMBER_JAVA = "io.cucumber.java"; + private static final String IO_CUCUMBER_JAVA8 = "io.cucumber.java8"; + + private final FullyQualified stepDefinitionsClass; + private final String replacementImport; + private final String template; + private final Object[] templateParameters; + + @Override + public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration cd, ExecutionContext ctx) { + J.ClassDeclaration classDeclaration = super.visitClassDeclaration(cd, ctx); + if (!TypeUtils.isOfType(classDeclaration.getType(), stepDefinitionsClass)) { + // We aren't looking at the specified class so return without making + // any modifications + return classDeclaration; + } + + // Remove implement of Java8 interfaces & imports; return retained + List retained = filterImplementingInterfaces(classDeclaration); + + // Import Given/When/Then or Before/After as applicable + maybeAddImport(replacementImport); + + // Remove empty constructor which might be left over after removing + // method invocations with typical usage + doAfterVisit(new JavaIsoVisitor() { + @Override + public J.MethodDeclaration visitMethodDeclaration(J.MethodDeclaration md, ExecutionContext p) { + J.MethodDeclaration methodDeclaration = super.visitMethodDeclaration(md, p); + if (methodDeclaration.isConstructor() && (methodDeclaration.getBody() == null + || methodDeclaration.getBody().getStatements().isEmpty())) { + // noinspection DataFlowIssue + return null; + } + return methodDeclaration; + } + }); + + // Remove nested braces from lambda body block inserted into new method + doAfterVisit(new org.openrewrite.java.cleanup.RemoveUnneededBlock()); + + // Remove unnecessary throws from templates that maybe-throw-exceptions + doAfterVisit(new org.openrewrite.java.cleanup.UnnecessaryThrows()); + + // Update implements & add new method + return classDeclaration + .withImplements(retained) + .withTemplate(JavaTemplate.builder(this::getCursor, template) + .javaParser( + () -> JavaParser.fromJavaVersion().classpath("cucumber-java", "cucumber-java8").build()) + .imports(replacementImport) + .build(), + coordinatesForNewMethod(classDeclaration.getBody()), + templateParameters); + } + + /** + * Remove imports & usage of Cucumber-Java8 interfaces. + * + * @return retained implementing interfaces + */ + private List filterImplementingInterfaces(J.ClassDeclaration classDeclaration) { + List retained = new ArrayList<>(); + for (TypeTree typeTree : Optional.ofNullable(classDeclaration.getImplements()) + .orElse(Collections.emptyList())) { + if (typeTree.getType() instanceof JavaType.Class) { + JavaType.Class clazz = (JavaType.Class) typeTree.getType(); + if (IO_CUCUMBER_JAVA8.equals(clazz.getPackageName())) { + maybeRemoveImport(clazz.getFullyQualifiedName()); + continue; + } + } + retained.add(typeTree); + } + return retained; + } + + /** + * Place new methods after the last cucumber annotated method, or after the + * constructor, or at end of class. + */ + private static JavaCoordinates coordinatesForNewMethod(J.Block body) { + // After last cucumber annotated method + return body.getStatements().stream() + .filter(J.MethodDeclaration.class::isInstance) + .map(firstMethod -> (J.MethodDeclaration) firstMethod) + .filter(method -> method.getAllAnnotations().stream() + .anyMatch(ann -> ann.getAnnotationType().getType() != null + && ((JavaType.Class) ann.getAnnotationType().getType()).getPackageName() + .startsWith(IO_CUCUMBER_JAVA))) + .map(method -> method.getCoordinates().after()) + .reduce((a, b) -> b) + // After last constructor + .orElseGet(() -> body.getStatements().stream() + .filter(J.MethodDeclaration.class::isInstance) + .map(firstMethod -> (J.MethodDeclaration) firstMethod) + .filter(J.MethodDeclaration::isConstructor) + .map(constructor -> constructor.getCoordinates().after()) + .reduce((a, b) -> b) + // At end of class + .orElseGet(() -> body.getCoordinates().lastStatement())); + } +} diff --git a/src/main/java/io/cucumber/migration/CucumberJava8HookDefinitionToCucumberJava.java b/src/main/java/io/cucumber/migration/CucumberJava8HookDefinitionToCucumberJava.java new file mode 100644 index 0000000..3c52e58 --- /dev/null +++ b/src/main/java/io/cucumber/migration/CucumberJava8HookDefinitionToCucumberJava.java @@ -0,0 +1,204 @@ +package io.cucumber.migration; + +import lombok.EqualsAndHashCode; +import lombok.Value; +import lombok.With; +import org.openrewrite.Applicability; +import org.openrewrite.ExecutionContext; +import org.openrewrite.Recipe; +import org.openrewrite.TreeVisitor; +import org.openrewrite.internal.lang.Nullable; +import org.openrewrite.java.JavaVisitor; +import org.openrewrite.java.MethodMatcher; +import org.openrewrite.java.search.UsesMethod; +import org.openrewrite.java.tree.Expression; +import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.JavaType.Primitive; +import org.openrewrite.marker.SearchResult; + +import java.time.Duration; +import java.util.List; + +@Value +@EqualsAndHashCode(callSuper = true) +public class CucumberJava8HookDefinitionToCucumberJava extends Recipe { + + private static final String IO_CUCUMBER_JAVA8 = "io.cucumber.java8"; + private static final String IO_CUCUMBER_JAVA8_HOOK_BODY = "io.cucumber.java8.HookBody"; + private static final String IO_CUCUMBER_JAVA8_HOOK_NO_ARGS_BODY = "io.cucumber.java8.HookNoArgsBody"; + + private static final String HOOK_BODY_DEFINITION = IO_CUCUMBER_JAVA8 + + ".LambdaGlue *(.., " + IO_CUCUMBER_JAVA8_HOOK_BODY + ")"; + private static final String HOOK_NO_ARGS_BODY_DEFINITION = IO_CUCUMBER_JAVA8 + + ".LambdaGlue *(.., " + IO_CUCUMBER_JAVA8_HOOK_NO_ARGS_BODY + ")"; + + private static final MethodMatcher HOOK_BODY_DEFINITION_METHOD_MATCHER = new MethodMatcher( + HOOK_BODY_DEFINITION); + private static final MethodMatcher HOOK_NO_ARGS_BODY_DEFINITION_METHOD_MATCHER = new MethodMatcher( + HOOK_NO_ARGS_BODY_DEFINITION); + + @Override + protected TreeVisitor getSingleSourceApplicableTest() { + return Applicability.or( + new UsesMethod<>(HOOK_BODY_DEFINITION, true), + new UsesMethod<>(HOOK_NO_ARGS_BODY_DEFINITION, true)); + } + + @Override + public String getDisplayName() { + return "Replace Cucumber-Java8 hook definition with Cucumber-Java"; + } + + @Override + public String getDescription() { + return "Replace LambdaGlue hook definitions with new annotated methods with the same body."; + } + + @Override + public @Nullable Duration getEstimatedEffortPerOccurrence() { + return Duration.ofMinutes(10); + } + + @Override + protected TreeVisitor getVisitor() { + return new CucumberJava8HooksVisitor(); + } + + static final class CucumberJava8HooksVisitor extends JavaVisitor { + @Override + public J visitMethodInvocation(J.MethodInvocation mi, ExecutionContext p) { + J.MethodInvocation methodInvocation = (J.MethodInvocation) super.visitMethodInvocation(mi, p); + if (!HOOK_BODY_DEFINITION_METHOD_MATCHER.matches(methodInvocation) + && !HOOK_NO_ARGS_BODY_DEFINITION_METHOD_MATCHER.matches(methodInvocation)) { + return methodInvocation; + } + + // Replacement annotations can only handle literals or constants + if (methodInvocation.getArguments().stream() + .anyMatch(arg -> !(arg instanceof J.Literal) && !(arg instanceof J.Lambda))) { + return SearchResult.found(methodInvocation, "TODO Migrate manually"); + } + + // Extract arguments passed to method + HookArguments hookArguments = parseHookArguments(methodInvocation.getSimpleName(), + methodInvocation.getArguments()); + + // Add new template method at end of class declaration + J.ClassDeclaration parentClass = getCursor() + .dropParentUntil(J.ClassDeclaration.class::isInstance) + .getValue(); + doAfterVisit(new CucumberJava8ClassVisitor( + parentClass.getType(), + hookArguments.replacementImport(), + hookArguments.template(), + hookArguments.parameters())); + + // Remove original method invocation; it's replaced in the above + // visitor + // noinspection DataFlowIssue + return null; + } + + /** + * Parse up to three arguments: - last one is always a Lambda; - first + * can also be a String or int. - second can be an int; + */ + HookArguments parseHookArguments(String methodName, List arguments) { + // Lambda is always last, and can either contain a body with + // Scenario argument, or without + int argumentsSize = arguments.size(); + Expression lambdaArgument = arguments.get(argumentsSize - 1); + HookArguments hookArguments = new HookArguments( + methodName, + null, + null, + (J.Lambda) lambdaArgument); + if (argumentsSize == 1) { + return hookArguments; + } + + J.Literal firstArgument = (J.Literal) arguments.get(0); + if (argumentsSize == 2) { + // First argument is either a String or an int + if (firstArgument.getType() == Primitive.String) { + return hookArguments.withTagExpression((String) firstArgument.getValue()); + } + return hookArguments.withOrder((Integer) firstArgument.getValue()); + } + // First argument is always a String, second argument always an int + return hookArguments + .withTagExpression((String) firstArgument.getValue()) + .withOrder((Integer) ((J.Literal) arguments.get(1)).getValue()); + } + } + +} + +@Value +class HookArguments { + + String annotationName; + @Nullable + @With + String tagExpression; + @Nullable + @With + Integer order; + J.Lambda lambda; + + String replacementImport() { + return String.format("io.cucumber.java.%s", annotationName); + } + + String template() { + return "@#{}#{}\npublic void #{}(#{}) throws Exception {\n\t#{any()}\n}"; + } + + private String formatAnnotationArguments() { + if (tagExpression == null && order == null) { + return ""; + } + StringBuilder template = new StringBuilder(); + template.append('('); + if (order != null) { + template.append("order = ").append(order); + if (tagExpression != null) { + template.append(", value = \"").append(tagExpression).append('"'); + } + } else { + template.append('"').append(tagExpression).append('"'); + } + template.append(')'); + return template.toString(); + } + + private String formatMethodName() { + return String.format("%s%s%s", + annotationName + .replaceFirst("^Before", "before") + .replaceFirst("^After", "after"), + tagExpression == null ? "" + : "_tag_" + tagExpression + .replaceAll("[^A-Za-z0-9]", "_"), + order == null ? "" : "_order_" + order); + } + + private String formatMethodArguments() { + J firstLambdaParameter = lambda.getParameters().getParameters().get(0); + if (firstLambdaParameter instanceof J.VariableDeclarations) { + return String.format("io.cucumber.java.Scenario %s", + ((J.VariableDeclarations) firstLambdaParameter).getVariables().get(0).getName()); + } + return ""; + } + + public Object[] parameters() { + return new Object[] { + annotationName, + formatAnnotationArguments(), + formatMethodName(), + formatMethodArguments(), + lambda.getBody() }; + } + +} diff --git a/src/main/java/io/cucumber/migration/CucumberJava8StepDefinitionToCucumberJava.java b/src/main/java/io/cucumber/migration/CucumberJava8StepDefinitionToCucumberJava.java new file mode 100644 index 0000000..0807be8 --- /dev/null +++ b/src/main/java/io/cucumber/migration/CucumberJava8StepDefinitionToCucumberJava.java @@ -0,0 +1,172 @@ +/* + * Copyright 2022 the original author or authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cucumber.migration; + +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.openrewrite.ExecutionContext; +import org.openrewrite.Recipe; +import org.openrewrite.TreeVisitor; +import org.openrewrite.internal.lang.Nullable; +import org.openrewrite.java.JavaVisitor; +import org.openrewrite.java.MethodMatcher; +import org.openrewrite.java.search.UsesMethod; +import org.openrewrite.java.tree.Expression; +import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.TypeUtils; +import org.openrewrite.marker.SearchResult; + +import java.time.Duration; +import java.util.List; +import java.util.stream.Collectors; + +@Value +@EqualsAndHashCode(callSuper = true) +public class CucumberJava8StepDefinitionToCucumberJava extends Recipe { + + private static final String IO_CUCUMBER_JAVA8_STEP_DEFINITION = "io.cucumber.java8.* *(String, ..)"; + private static final String IO_CUCUMBER_JAVA8_STEP_DEFINITION_BODY = "io.cucumber.java8.StepDefinitionBody"; + private static final MethodMatcher STEP_DEFINITION_METHOD_MATCHER = new MethodMatcher( + IO_CUCUMBER_JAVA8_STEP_DEFINITION); + + @Override + protected TreeVisitor getSingleSourceApplicableTest() { + return new UsesMethod<>(IO_CUCUMBER_JAVA8_STEP_DEFINITION, true); + } + + @Override + public String getDisplayName() { + return "Replace Cucumber-Java8 step definitions with Cucumber-Java"; + } + + @Override + public String getDescription() { + return "Replace StepDefinitionBody methods with StepDefinitionAnnotations on new methods with the same body."; + } + + @Override + public @Nullable Duration getEstimatedEffortPerOccurrence() { + return Duration.ofMinutes(10); + } + + @Override + protected TreeVisitor getVisitor() { + return new CucumberStepDefinitionBodyVisitor(); + } + + static final class CucumberStepDefinitionBodyVisitor extends JavaVisitor { + @Override + public J visitMethodInvocation(J.MethodInvocation methodInvocation, ExecutionContext p) { + J.MethodInvocation m = (J.MethodInvocation) super.visitMethodInvocation(methodInvocation, p); + if (!STEP_DEFINITION_METHOD_MATCHER.matches(m)) { + return m; + } + + // Skip any methods not containing a second argument, such as + // Scenario.log(String) + List arguments = m.getArguments(); + if (arguments.size() < 2) { + return m; + } + + // Annotations require a String literal + Expression stringExpression = arguments.get(0); + if (!(stringExpression instanceof J.Literal)) { + return SearchResult.found(m, "TODO Migrate manually"); + } + J.Literal literal = (J.Literal) stringExpression; + + // Extract step definition body, when applicable + Expression possibleStepDefinitionBody = arguments.get(1); // Always + // available + // after a + // first + // String + // argument + if (!(possibleStepDefinitionBody instanceof J.Lambda) + || !TypeUtils.isAssignableTo(IO_CUCUMBER_JAVA8_STEP_DEFINITION_BODY, + possibleStepDefinitionBody.getType())) { + return SearchResult.found(m, "TODO Migrate manually"); + } + J.Lambda lambda = (J.Lambda) possibleStepDefinitionBody; + + StepDefinitionArguments stepArguments = new StepDefinitionArguments( + m.getSimpleName(), literal, lambda); + + // Determine step definitions class name + J.ClassDeclaration parentClass = getCursor() + .dropParentUntil(J.ClassDeclaration.class::isInstance) + .getValue(); + if (m.getMethodType() == null) { + return m; + } + String replacementImport = String.format("%s.%s", + m.getMethodType().getDeclaringType().getFullyQualifiedName() + .replace("java8", "java").toLowerCase(), + m.getSimpleName()); + doAfterVisit(new CucumberJava8ClassVisitor( + parentClass.getType(), + replacementImport, + stepArguments.template(), + stepArguments.parameters())); + + // Remove original method invocation; it's replaced in the above + // visitor + // noinspection DataFlowIssue + return null; + } + } + +} + +@Value +class StepDefinitionArguments { + + String annotationName; + J.Literal cucumberExpression; + J.Lambda lambda; + + String template() { + return "@#{}(#{any()})\npublic void #{}(#{}) throws Exception {\n\t#{any()}\n}"; + } + + private String formatMethodName() { + return ((String) cucumberExpression.getValue()) + .replaceAll("\\s+", "_") + .replaceAll("[^A-Za-z0-9_]", "") + .toLowerCase(); + } + + private String formatMethodArguments() { + // TODO Type loss here, but my attempts to pass these as J failed: + // __P__./*__p0__*/p () + return lambda.getParameters().getParameters().stream() + .filter(j -> j instanceof J.VariableDeclarations) + .map(j -> (J.VariableDeclarations) j) + .map(J.VariableDeclarations::toString) + .collect(Collectors.joining(", ")); + } + + Object[] parameters() { + return new Object[] { + annotationName, + cucumberExpression, + formatMethodName(), + formatMethodArguments(), + lambda.getBody() }; + } + +} diff --git a/src/main/java/io/cucumber/migration/DropSummaryPrinter.java b/src/main/java/io/cucumber/migration/DropSummaryPrinter.java new file mode 100644 index 0000000..2db46f8 --- /dev/null +++ b/src/main/java/io/cucumber/migration/DropSummaryPrinter.java @@ -0,0 +1,81 @@ +package io.cucumber.migration; + +import org.openrewrite.ExecutionContext; +import org.openrewrite.Recipe; +import org.openrewrite.TreeVisitor; +import org.openrewrite.internal.ListUtils; +import org.openrewrite.internal.lang.Nullable; +import org.openrewrite.java.ChangeType; +import org.openrewrite.java.JavaIsoVisitor; +import org.openrewrite.java.RemoveImport; +import org.openrewrite.java.search.UsesType; +import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.TypeUtils; + +import java.time.Duration; +import java.util.Collection; +import java.util.Objects; +import java.util.stream.Stream; + +public class DropSummaryPrinter extends Recipe { + + private static final String IO_CUCUMBER_PLUGIN_SUMMARY_PRINTER = "io.cucumber.plugin.SummaryPrinter"; + private static final String IO_CUCUMBER_PLUGIN_PLUGIN = "io.cucumber.plugin.Plugin"; + + @Override + protected TreeVisitor getSingleSourceApplicableTest() { + return new UsesType<>(IO_CUCUMBER_PLUGIN_SUMMARY_PRINTER); + } + + @Override + public String getDisplayName() { + return "Drop SummaryPrinter"; + } + + @Override + public String getDescription() { + return "Replace SummaryPrinter with Plugin, if not already present."; + } + + @Override + public @Nullable Duration getEstimatedEffortPerOccurrence() { + return Duration.ofMinutes(1); + } + + @Override + protected TreeVisitor getVisitor() { + return new DropSummaryPrinterVisitor(); + } + + static final class DropSummaryPrinterVisitor extends JavaIsoVisitor { + @Override + public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration cd, ExecutionContext p) { + J.ClassDeclaration classDeclaration = super.visitClassDeclaration(cd, p); + boolean implementsSummaryPrinter = Stream.of(classDeclaration.getImplements()) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .anyMatch(t -> TypeUtils.isOfClassType(t.getType(), IO_CUCUMBER_PLUGIN_SUMMARY_PRINTER)); + boolean alreadyImplementsPlugin = Stream.of(classDeclaration.getImplements()) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .anyMatch(t -> TypeUtils.isOfClassType(t.getType(), IO_CUCUMBER_PLUGIN_PLUGIN)); + if (!implementsSummaryPrinter) { + return classDeclaration; + } + doAfterVisit(new ChangeType( + IO_CUCUMBER_PLUGIN_SUMMARY_PRINTER, + IO_CUCUMBER_PLUGIN_PLUGIN, + true)); + doAfterVisit(new RemoveImport<>(IO_CUCUMBER_PLUGIN_SUMMARY_PRINTER)); + return classDeclaration.withImplements(ListUtils.map(classDeclaration.getImplements(), i -> { + // Remove duplicate implements + if (TypeUtils.isOfClassType(i.getType(), IO_CUCUMBER_PLUGIN_SUMMARY_PRINTER) + && alreadyImplementsPlugin) { + return null; + } + return i; + })); + } + } + +} diff --git a/src/main/java/io/cucumber/migration/RegexToCucumberExpression.java b/src/main/java/io/cucumber/migration/RegexToCucumberExpression.java new file mode 100644 index 0000000..3809202 --- /dev/null +++ b/src/main/java/io/cucumber/migration/RegexToCucumberExpression.java @@ -0,0 +1,134 @@ +package io.cucumber.migration; + +import org.openrewrite.ExecutionContext; +import org.openrewrite.Recipe; +import org.openrewrite.TreeVisitor; +import org.openrewrite.internal.ListUtils; +import org.openrewrite.internal.lang.Nullable; +import org.openrewrite.java.JavaIsoVisitor; +import org.openrewrite.java.search.UsesType; +import org.openrewrite.java.tree.Expression; +import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.JavaType; +import org.openrewrite.java.tree.TypeUtils; + +import java.time.Duration; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Stream; + +public class RegexToCucumberExpression extends Recipe { + + private static final String IO_CUCUMBER_JAVA = "io.cucumber.java"; + private static final String IO_CUCUMBER_JAVA_STEP_DEFINITION = "io.cucumber.java.*.*"; + + @Override + protected TreeVisitor getSingleSourceApplicableTest() { + return new UsesType<>(IO_CUCUMBER_JAVA_STEP_DEFINITION); + } + + @Override + public String getDisplayName() { + return "Replace Cucumber-Java step definition regexes with Cucumber expressions"; + } + + @Override + public String getDescription() { + return "Strip regex prefix and suffix from step annotation expressions arguments where possible."; + } + + @Override + public @Nullable Duration getEstimatedEffortPerOccurrence() { + return Duration.ofMinutes(1); + } + + @Override + protected TreeVisitor getVisitor() { + return new CucumberStepDefinitionBodyVisitor(); + } + + static final class CucumberStepDefinitionBodyVisitor extends JavaIsoVisitor { + + @Override + public J.MethodDeclaration visitMethodDeclaration(J.MethodDeclaration m, ExecutionContext p) { + J.MethodDeclaration methodDeclaration = super.visitMethodDeclaration(m, p); + return methodDeclaration.withLeadingAnnotations(ListUtils.map(methodDeclaration.getLeadingAnnotations(), + ann -> replaceRegexWithCucumberExpression(methodDeclaration, ann))); + } + + private static J.Annotation replaceRegexWithCucumberExpression( + // For when we want to match regexes with method arguments for + // replacement cucumber expressions + // https://github.com/cucumber/cucumber-expressions#parameter-types + J.MethodDeclaration methodDeclaration, + J.Annotation annotation + ) { + + // Skip if not a cucumber annotation + JavaType.FullyQualified annoFqn = TypeUtils.asFullyQualified(annotation.getType()); + if (annoFqn == null || !annoFqn.getPackageName().startsWith(IO_CUCUMBER_JAVA)) { + return annotation; + } + + List arguments = annotation.getArguments(); + Optional possibleExpression = Stream.of(arguments) + .filter(Objects::nonNull) + .filter(list -> list.size() == 1) + .flatMap(Collection::stream) + .filter(J.Literal.class::isInstance) + .map(e -> (J.Literal) e) + .map(l -> (String) l.getValue()) + // https://github.com/cucumber/cucumber-expressions/blob/main/java/heuristics.adoc + .filter(s -> s != null && (s.startsWith("^") || s.endsWith("$") || leadingAndTrailingSlash(s))) + .findFirst(); + if (!possibleExpression.isPresent()) { + return annotation; + } + + // Strip leading/trailing regex anchors + String replacement = stripAnchors(possibleExpression.get()); + + // Back off when special characters are encountered in regex + if (Stream.of("(", ")", "{", "}", "[", "]", "?", "*", "+", "/", "\\", "^", "|") + .anyMatch(replacement::contains)) { + return annotation; + } + + // Replace regular expression with cucumber expression + final String finalReplacement = String.format("\"%s\"", replacement); + return annotation.withArguments(ListUtils.map(annotation.getArguments(), arg -> ((J.Literal) arg) + .withValue(finalReplacement) + .withValueSource(finalReplacement))); + } + + private static String stripAnchors(final String initialExpression) { + if (leadingAndTrailingSlash(initialExpression)) { + return initialExpression.substring(1, initialExpression.length() - 1); + } + + // The presence of anchors assumes a Regular Expression, even if + // only one of the anchors are present + String replacement = initialExpression; + if (replacement.startsWith("^")) { + replacement = replacement.substring(1); + } + if (replacement.endsWith("$")) { + replacement = replacement.substring(0, replacement.length() - 1); + } + + // Prevent conversion of `^/hello world/$` to `/hello world/` + if (leadingAndTrailingSlash(replacement)) { + return initialExpression; + } + + return replacement; + } + + private static boolean leadingAndTrailingSlash(final String initialExpression) { + return initialExpression.startsWith("/") && initialExpression.endsWith("/"); + } + } + +} diff --git a/src/main/resources/META-INF/rewrite/cucumber.yml b/src/main/resources/META-INF/rewrite/cucumber.yml new file mode 100755 index 0000000..91d0511 --- /dev/null +++ b/src/main/resources/META-INF/rewrite/cucumber.yml @@ -0,0 +1,85 @@ +--- +type: specs.openrewrite.org/v1beta/recipe +name: io.cucumber.migration.UpgradeCucumber7x +displayName: Upgrade to Cucumber-JVM 7.x +description: Upgrade to Cucumber-JVM 7.x from any previous version. +tags: + - testing + - cucumber +recipeList: + - io.cucumber.migration.UpgradeCucumber5x + - io.cucumber.migration.CucumberJava8ToJava + - io.cucumber.migration.DropSummaryPrinter + - io.cucumber.migration.RegexToCucumberExpression + - io.cucumber.migration.CucumberToJunitPlatformSuite + - org.openrewrite.maven.UpgradeDependencyVersion: + groupId: io.cucumber + artifactId: "*" + newVersion: 7.x +--- +type: specs.openrewrite.org/v1beta/recipe +name: io.cucumber.migration.UpgradeCucumber5x +displayName: Upgrade to Cucumber-JVM 5.x +description: Upgrade to Cucumber-JVM 5.x from any previous version. +tags: + - testing + - cucumber +recipeList: + - io.cucumber.migration.UpgradeCucumber2x + - org.openrewrite.java.ChangePackage: + oldPackageName: cucumber.api + newPackageName: io.cucumber +--- +type: specs.openrewrite.org/v1beta/recipe +name: io.cucumber.migration.UpgradeCucumber2x +displayName: Upgrade to Cucumber-JVM 2.x +description: Upgrade to Cucumber-JVM 2.x from any previous version. +tags: + - testing + - cucumber +recipeList: + - org.openrewrite.maven.ChangeDependencyGroupIdAndArtifactId: + oldGroupId: info.cukes + oldArtifactId: cucumber-java + newGroupId: io.cucumber + newArtifactId: cucumber-java + - org.openrewrite.maven.ChangeDependencyGroupIdAndArtifactId: + oldGroupId: info.cukes + oldArtifactId: cucumber-java8 + newGroupId: io.cucumber + newArtifactId: cucumber-java8 +--- +type: specs.openrewrite.org/v1beta/recipe +name: io.cucumber.migration.CucumberJava8ToJava +displayName: Cucumber-Java8 migration to Cucumber-Java +description: Migrates Cucumber-Java8 step definitions and LambdaGlue hooks to Cucumber-Java annotated methods. +tags: + - testing + - cucumber +recipeList: + - io.cucumber.migration.CucumberJava8HookDefinitionToCucumberJava + - io.cucumber.migration.CucumberJava8StepDefinitionToCucumberJava + - org.openrewrite.maven.ChangeDependencyGroupIdAndArtifactId: + oldGroupId: io.cucumber + oldArtifactId: cucumber-java8 + newGroupId: io.cucumber + newArtifactId: cucumber-java + - org.openrewrite.java.ChangePackage: + oldPackageName: io.cucumber.java8 + newPackageName: io.cucumber.java +--- +type: specs.openrewrite.org/v1beta/recipe +name: io.cucumber.migration.CucumberToJunitPlatformSuite +displayName: Cucumber to JUnit Test Suites +description: Migrates Cucumber tests to JUnit Test Suites. +tags: + - testing + - cucumber +recipeList: + - io.cucumber.migration.CucumberAnnotationToSuite + - org.openrewrite.maven.AddDependency: + groupId: org.junit.platform + artifactId: junit-platform-suite + version: 1.9.x + onlyIfUsing: org.junit.platform.suite.api.* + acceptTransitive: true diff --git a/src/test/java/io/cucumber/migration/CucumberAnnotationToSuiteTest.java b/src/test/java/io/cucumber/migration/CucumberAnnotationToSuiteTest.java new file mode 100644 index 0000000..22110bc --- /dev/null +++ b/src/test/java/io/cucumber/migration/CucumberAnnotationToSuiteTest.java @@ -0,0 +1,47 @@ +package io.cucumber.migration; + +import org.junit.jupiter.api.Test; +import org.openrewrite.Issue; +import org.openrewrite.java.JavaParser; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.java.Assertions.java; + +@Issue("https://github.com/openrewrite/rewrite-testing-frameworks/issues/264") +class CucumberAnnotationToSuiteTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new CucumberAnnotationToSuite()) + .parser(JavaParser.fromJavaVersion().classpath("cucumber-junit-platform-engine", + "junit-platform-suite-api")); + } + + @Test + void shouldReplaceCucumberAnnotationWithSuiteWithSelectedClasspathResource() { + // language=java + rewriteRun( + java( + """ + package com.example.app; + + import io.cucumber.junit.platform.engine.Cucumber; + + @Cucumber + public class CucumberJava8Definitions { + } + """, + """ + package com.example.app; + + import org.junit.platform.suite.api.SelectClasspathResource; + import org.junit.platform.suite.api.Suite; + + @Suite + @SelectClasspathResource("com/example/app") + public class CucumberJava8Definitions { + } + """)); + } +} diff --git a/src/test/java/io/cucumber/migration/CucumberJava8ToCucumberJavaTest.java b/src/test/java/io/cucumber/migration/CucumberJava8ToCucumberJavaTest.java new file mode 100644 index 0000000..65f3505 --- /dev/null +++ b/src/test/java/io/cucumber/migration/CucumberJava8ToCucumberJavaTest.java @@ -0,0 +1,654 @@ +package io.cucumber.migration; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.openrewrite.Issue; +import org.openrewrite.java.JavaParser; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.java.Assertions.java; +import static org.openrewrite.java.Assertions.version; + +@Issue("https://github.com/openrewrite/rewrite-testing-frameworks/issues/259") +class CucumberJava8ToCucumberJavaTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe("/META-INF/rewrite/cucumber.yml", "io.cucumber.migration.CucumberJava8ToJava"); + spec.parser(JavaParser.fromJavaVersion() + .logCompilationWarningsAndErrors(true) + .classpath("junit-jupiter-api", "cucumber-java", "cucumber-java8")); + } + + @SuppressWarnings("CodeBlock2Expr") + @Test + void cucumberJava8HooksAndSteps() { + rewriteRun( + version( + // language=java + java( + """ + package com.example.app; + + import io.cucumber.java8.En; + import io.cucumber.java8.Scenario; + import io.cucumber.java8.Status; + + import static org.junit.jupiter.api.Assertions.assertEquals; + + public class CucumberJava8Definitions implements En { + + private int a; + + public CucumberJava8Definitions() { + Before(() -> { + a = 0; + }); + When("I add {int}", (Integer b) -> { + a += b; + }); + Then("I expect {int}", (Integer c) -> assertEquals(c, a)); + + After((Scenario scn) -> { + if (scn.getStatus() == Status.FAILED) { + scn.log("failed"); + } + }); + + } + + }""", """ + package com.example.app; + + import io.cucumber.java.After; + import io.cucumber.java.Before; + import io.cucumber.java.en.Then; + import io.cucumber.java.en.When; + import io.cucumber.java.Scenario; + import io.cucumber.java.Status; + + import static org.junit.jupiter.api.Assertions.assertEquals; + + public class CucumberJava8Definitions { + + private int a; + + @Before + public void before() { + a = 0; + } + + @After + public void after(io.cucumber.java.Scenario scn) { + if (scn.getStatus() == Status.FAILED) { + scn.log("failed"); + } + } + + @When("I add {int}") + public void i_add_int(Integer b) { + a += b; + } + + @Then("I expect {int}") + public void i_expect_int(Integer c) { + assertEquals(c, a); + } + + } + """), + 17)); + } + + @Nested + class StepMigration { + @SuppressWarnings("CodeBlock2Expr") + @Test + void cucumberJava8SampleToJavaSample() { + rewriteRun( + version( + // language=java + java( + """ + package com.example.app; + + import io.cucumber.java8.En; + + import static org.junit.jupiter.api.Assertions.assertEquals; + + public class CalculatorStepDefinitions implements En { + private RpnCalculator calc; + + public CalculatorStepDefinitions() { + Given("a calculator I just turned on", () -> { + calc = new RpnCalculator(); + }); + + When("I add {int} and {int}", (Integer arg1, Integer arg2) -> { + calc.push(arg1); + calc.push(arg2); + calc.push("+"); + }); + + Then("the result is {double}", (Double expected) -> assertEquals(expected, calc.value())); + } + + static class RpnCalculator { + void push(Object string) { + } + + public Double value() { + return Double.NaN; + } + } + } + """, + """ + package com.example.app; + + import io.cucumber.java.en.Given; + import io.cucumber.java.en.Then; + import io.cucumber.java.en.When; + + import static org.junit.jupiter.api.Assertions.assertEquals; + + public class CalculatorStepDefinitions { + private RpnCalculator calc; + + @Given("a calculator I just turned on") + public void a_calculator_i_just_turned_on() { + calc = new RpnCalculator(); + } + + @When("I add {int} and {int}") + public void i_add_int_and_int(Integer arg1, Integer arg2) { + calc.push(arg1); + calc.push(arg2); + calc.push("+"); + } + + @Then("the result is {double}") + public void the_result_is_double(Double expected) { + assertEquals(expected, calc.value()); + } + + static class RpnCalculator { + void push(Object string) { + } + + public Double value() { + return Double.NaN; + } + } + } + """), + 17)); + } + + @SuppressWarnings("CodeBlock2Expr") + @Test + void methodInvocationsOutsideConstructor() { + rewriteRun( + version( + // language=java + java( + """ + package com.example.app; + + import io.cucumber.java8.En; + + public class CalculatorStepDefinitions implements En { + private int cakes = 0; + + public CalculatorStepDefinitions() { + delegated(); + } + + private void delegated() { + Given("{int} cakes", (Integer i) -> { + cakes = i; + }); + } + }""", + """ + package com.example.app; + + import io.cucumber.java.en.Given; + + public class CalculatorStepDefinitions { + private int cakes = 0; + + public CalculatorStepDefinitions() { + delegated(); + } + + @Given("{int} cakes") + public void int_cakes(Integer i) { + cakes = i; + } + + private void delegated() { + } + }"""), + 17)); + } + + @Test + void retainWhitespaceAndCommentsInLambdaBody() { + rewriteRun( + version( + // language=java + java( + """ + package com.example.app; + + import io.cucumber.java8.En; + + public class CalculatorStepDefinitions implements En { + public CalculatorStepDefinitions() { + Given("{int} plus {int}", (Integer a, Integer b) -> { + + // Lambda body comment + int c = a + b; + }); + } + } + """, + """ + package com.example.app; + + import io.cucumber.java.en.Given; + + public class CalculatorStepDefinitions { + + @Given("{int} plus {int}") + public void int_plus_int(Integer a, Integer b) { + + // Lambda body comment + int c = a + b; + } + } + """), + 17)); + } + + @Test + void retainThrowsException() { + rewriteRun( + version( + // language=java + java( + """ + package com.example.app; + + import io.cucumber.java8.En; + + public class CalculatorStepDefinitions implements En { + public CalculatorStepDefinitions() { + Given("a thrown exception", () -> { + throw new Exception(); + }); + } + } + """, + """ + package com.example.app; + + import io.cucumber.java.en.Given; + + public class CalculatorStepDefinitions { + + @Given("a thrown exception") + public void a_thrown_exception() throws Exception { + throw new Exception(); + } + } + """), + 17)); + } + + @Test + void replaceWhenNotUsingStringConstant() { + rewriteRun( + version( + // language=java + java( + """ + package com.example.app; + + import io.cucumber.java8.En; + + public class CalculatorStepDefinitions implements En { + public CalculatorStepDefinitions() { + String expression = "{int} plus {int}"; + Given(expression, (Integer a, Integer b) -> { + int c = a + b; + }); + } + } + """, + """ + package com.example.app; + + import io.cucumber.java.En; + + public class CalculatorStepDefinitions implements En { + public CalculatorStepDefinitions() { + String expression = "{int} plus {int}"; + /*~~(TODO Migrate manually)~~>*/Given(expression, (Integer a, Integer b) -> { + int c = a + b; + }); + } + } + """), + 17)); + } + + @Test + void replaceWhenUsingStringConstant() { + // For simplicity, we only replace when using a String literal for + // now + rewriteRun( + version( + // language=java + java( + """ + package com.example.app; + + import io.cucumber.java8.En; + + public class CalculatorStepDefinitions implements En { + private static final String expression = "{int} plus {int}"; + public CalculatorStepDefinitions() { + Given(expression, (Integer a, Integer b) -> { + int c = a + b; + }); + } + } + """, + """ + package com.example.app; + + import io.cucumber.java.En; + + public class CalculatorStepDefinitions implements En { + private static final String expression = "{int} plus {int}"; + public CalculatorStepDefinitions() { + /*~~(TODO Migrate manually)~~>*/Given(expression, (Integer a, Integer b) -> { + int c = a + b; + }); + } + } + """), + 17)); + } + + @Test + void replaceMethodReference() { + // For simplicity, we only replace when using lambda for now + rewriteRun( + version( + // language=java + java( + """ + package com.example.app; + + import io.cucumber.java8.En; + + public class CalculatorStepDefinitions implements En { + public CalculatorStepDefinitions() { + Given("{int} plus {int}", Integer::sum); + } + }""", """ + package com.example.app; + + import io.cucumber.java.En; + + public class CalculatorStepDefinitions implements En { + public CalculatorStepDefinitions() { + /*~~(TODO Migrate manually)~~>*/Given("{int} plus {int}", Integer::sum); + } + } + """), + 17)); + } + + } + + @Nested + class HookMigration { + @SuppressWarnings("CodeBlock2Expr") + @Test + void cucumberJava8Hooks() { + rewriteRun( + version( + // language=java + java( + """ + package com.example.app; + + import io.cucumber.java8.En; + import io.cucumber.java8.Scenario; + import io.cucumber.java8.Status; + + public class HookStepDefinitions implements En { + + private int a; + + public HookStepDefinitions() { + Before(() -> { + a = 0; + }); + + Before("abc", () -> a = 0); + + Before("not abc", 0, () -> { + a = 0; + }); + + Before(1, () -> { + a = 0; + }); + + Before(2, scn -> { + a = 0; + }); + + After((Scenario scn) -> { + if (scn.getStatus() == Status.FAILED) { + scn.log("after scenario"); + } + }); + + After("abc", (Scenario scn) -> { + scn.log("after scenario"); + }); + + AfterStep(scn -> { + a = 0; + }); + } + + } + """, + """ + package com.example.app; + + import io.cucumber.java.After; + import io.cucumber.java.AfterStep; + import io.cucumber.java.Before; + import io.cucumber.java.Scenario; + import io.cucumber.java.Status; + + public class HookStepDefinitions { + + private int a; + + @Before + public void before() { + a = 0; + } + + @Before("abc") + public void before_tag_abc() { + a = 0; + } + + @Before(order = 0, value = "not abc") + public void before_tag_not_abc_order_0() { + a = 0; + } + + @Before(order = 1) + public void before_order_1() { + a = 0; + } + + @Before(order = 2) + public void before_order_2(io.cucumber.java.Scenario scn) { + a = 0; + } + + @After + public void after(io.cucumber.java.Scenario scn) { + if (scn.getStatus() == Status.FAILED) { + scn.log("after scenario"); + } + } + + @After("abc") + public void after_tag_abc(io.cucumber.java.Scenario scn) { + scn.log("after scenario"); + } + + @AfterStep + public void afterStep(io.cucumber.java.Scenario scn) { + a = 0; + } + + } + """), + 17)); + } + + @Test + void convertAnonymousClasses() { + // For simplicity, anonymous classes are not converted for now; it's + // not how cucumber-java8 usage was intended + rewriteRun( + spec -> spec.cycles(2), + version( + // language=java + java( + """ + package com.example.app; + + import io.cucumber.java8.En; + import io.cucumber.java8.HookBody; + import io.cucumber.java8.HookNoArgsBody; + import io.cucumber.java8.Scenario; + + public class HookStepDefinitions implements En { + + private int a; + + public HookStepDefinitions() { + Before(new HookNoArgsBody() { + @Override + public void accept() { + a = 0; + } + }); + + Before(new HookBody() { + @Override + public void accept(Scenario scenario) { + a = 0; + } + }); + } + + } + """, + """ + package com.example.app; + + import io.cucumber.java.En; + import io.cucumber.java.HookBody; + import io.cucumber.java.HookNoArgsBody; + import io.cucumber.java.Scenario; + + public class HookStepDefinitions implements En { + + private int a; + + public HookStepDefinitions() { + /*~~(TODO Migrate manually)~~>*/Before(new HookNoArgsBody() { + @Override + public void accept() { + a = 0; + } + }); + + /*~~(TODO Migrate manually)~~>*/Before(new HookBody() { + @Override + public void accept(Scenario scenario) { + a = 0; + } + }); + } + + } + """), + 17)); + } + + @Test + void convertMethodReference() { + // Not converted yet; the referred method can potentially be + // annotated and be made public + rewriteRun( + version( + // language=java + java( + """ + package com.example.app; + + import io.cucumber.java8.En; + + public class HookStepDefinitions implements En { + + private int a; + + public HookStepDefinitions() { + Before(this::connect); + } + + private void connect() { + } + + } + """, + """ + package com.example.app; + + import io.cucumber.java.En; + + public class HookStepDefinitions implements En { + + private int a; + + public HookStepDefinitions() { + /*~~(TODO Migrate manually)~~>*/Before(this::connect); + } + + private void connect() { + } + + } + """), + 17)); + } + } +} diff --git a/src/test/java/io/cucumber/migration/DropSummaryPrinterTest.java b/src/test/java/io/cucumber/migration/DropSummaryPrinterTest.java new file mode 100644 index 0000000..0a62e39 --- /dev/null +++ b/src/test/java/io/cucumber/migration/DropSummaryPrinterTest.java @@ -0,0 +1,69 @@ +package io.cucumber.migration; + +import org.junit.jupiter.api.Test; +import org.openrewrite.Issue; +import org.openrewrite.java.JavaParser; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.java.Assertions.java; +import static org.openrewrite.java.Assertions.version; + +@Issue("https://github.com/openrewrite/rewrite-testing-frameworks/issues/264") +class DropSummaryPrinterTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new DropSummaryPrinter()) + .parser(JavaParser.fromJavaVersion().classpath("cucumber-plugin")); + } + + @Test + void replaceSummaryPrinterWithPlugin() { + rewriteRun( + version( + // language=java + java( + """ + package com.example.app; + + import io.cucumber.plugin.SummaryPrinter; + + public class CucumberJava8Definitions implements SummaryPrinter { + }""", """ + package com.example.app; + + import io.cucumber.plugin.Plugin; + + public class CucumberJava8Definitions implements Plugin { + } + """), + 17)); + } + + @Test + void dontDuplicatePlugin() { + rewriteRun( + version( + // language=java + java( + """ + package com.example.app; + + import io.cucumber.plugin.Plugin; + import io.cucumber.plugin.SummaryPrinter; + + public class CucumberJava8Definitions implements Plugin, SummaryPrinter { + } + """, + """ + package com.example.app; + + import io.cucumber.plugin.Plugin; + + public class CucumberJava8Definitions implements Plugin { + } + """), + 17)); + } +} diff --git a/src/test/java/io/cucumber/migration/RegexToCucumberExpressionTest.java b/src/test/java/io/cucumber/migration/RegexToCucumberExpressionTest.java new file mode 100644 index 0000000..bdba3e9 --- /dev/null +++ b/src/test/java/io/cucumber/migration/RegexToCucumberExpressionTest.java @@ -0,0 +1,249 @@ +package io.cucumber.migration; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.openrewrite.Issue; +import org.openrewrite.java.JavaParser; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.java.Assertions.java; + +@Issue("https://github.com/openrewrite/rewrite-testing-frameworks/issues/264") +class RegexToCucumberExpressionTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new RegexToCucumberExpression()) + .parser(JavaParser.fromJavaVersion().classpath("junit-jupiter-api", "cucumber-java")); + } + + @Test + void regexToCucumberExpression() { + // language=java + rewriteRun(java(""" + package com.example.app; + + import io.cucumber.java.Before; + import io.cucumber.java.en.Given; + import io.cucumber.java.en.Then; + + import static org.junit.jupiter.api.Assertions.assertEquals; + + public class ExpressionDefinitions { + + private int a; + + @Before + public void before() { + a = 0; + } + + @Given("^five cukes$") + public void five_cukes() { + a = 5; + } + + @Then("^I expect (\\\\d+)$") + public void i_expect_int(Integer c) { + assertEquals(c, a); + } + + } + """, """ + package com.example.app; + + import io.cucumber.java.Before; + import io.cucumber.java.en.Given; + import io.cucumber.java.en.Then; + + import static org.junit.jupiter.api.Assertions.assertEquals; + + public class ExpressionDefinitions { + + private int a; + + @Before + public void before() { + a = 0; + } + + @Given("five cukes") + public void five_cukes() { + a = 5; + } + + @Then("^I expect (\\\\d+)$") + public void i_expect_int(Integer c) { + assertEquals(c, a); + } + + } + """)); + } + + @Nested + @DisplayName("should convert") + class ShouldConvert { + + @Test + void only_leading_anchor() { + // language=java + rewriteRun(java(""" + package com.example.app; + + import io.cucumber.java.en.Given; + + public class ExpressionDefinitions { + @Given("^five cukes") + public void five_cukes() { + } + }""", """ + package com.example.app; + + import io.cucumber.java.en.Given; + + public class ExpressionDefinitions { + @Given("five cukes") + public void five_cukes() { + } + }""")); + } + + @Test + void only_trailing_anchor() { + // language=java + rewriteRun(java(""" + package com.example.app; + + import io.cucumber.java.en.Given; + + public class ExpressionDefinitions { + @Given("five cukes$") + public void five_cukes() { + } + }""", """ + package com.example.app; + + import io.cucumber.java.en.Given; + + public class ExpressionDefinitions { + @Given("five cukes") + public void five_cukes() { + } + }""")); + } + + @Test + void forward_slashes() { + // language=java + rewriteRun(java(""" + package com.example.app; + + import io.cucumber.java.en.Given; + + public class ExpressionDefinitions { + @Given("/five cukes/") + public void five_cukes() { + } + }""", """ + package com.example.app; + + import io.cucumber.java.en.Given; + + public class ExpressionDefinitions { + @Given("five cukes") + public void five_cukes() { + } + }""")); + } + + } + + @Nested + @DisplayName("should not convert") + class ShouldNotConvert { + + @Test + void unrecognized_capturing_groups() { + // language=java + rewriteRun(java(""" + package com.example.app; + + import io.cucumber.java.en.Given; + + public class ExpressionDefinitions { + @Given("^some (foo|bar)$") + public void five_cukes(String fooOrBar) { + } + }""")); + } + + @Test + void integer_matchers() { + // language=java + rewriteRun(java(""" + package com.example.app; + + import io.cucumber.java.en.Given; + + public class ExpressionDefinitions { + @Given("^(\\\\d+) cukes$") + public void int_cukes(int cukes) { + } + }""")); + } + + @Test + void regex_question_mark_optional() { + // language=java + rewriteRun(java(""" + package com.example.app; + + import io.cucumber.java.en.Given; + + public class ExpressionDefinitions { + @Given("^cukes?$") + public void cukes() { + } + }""")); + } + + @Test + void regex_one_or_more() { + // language=java + rewriteRun(java(""" + package com.example.app; + + import io.cucumber.java.en.Given; + + public class ExpressionDefinitions { + @Given("^cukes+$") + public void cukes() { + } + }""")); + } + + @Test + void disabled() { + // language=java + rewriteRun(java(""" + package com.example.app; + + import io.cucumber.java.en.Given; + import org.junit.jupiter.api.Disabled; + + public class ExpressionDefinitions { + @Disabled("/for now/") + public void disabled() { + } + @Given("trigger getSingleSourceApplicableTest") + public void trigger() { + } + }""")); + } + + } + +}