Skip to content

Commit

Permalink
Retain non-migratable annotation arguments with a warning comment
Browse files Browse the repository at this point in the history
Closes #4
  • Loading branch information
Philzen committed Jun 24, 2024
1 parent e5630dd commit 6abd88d
Show file tree
Hide file tree
Showing 3 changed files with 340 additions and 100 deletions.
189 changes: 123 additions & 66 deletions src/main/java/org/philzen/oss/testng/UpdateTestAnnotationToJunit5.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.philzen.oss.testng;

import lombok.EqualsAndHashCode;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import org.openrewrite.ExecutionContext;
import org.openrewrite.Preconditions;
Expand All @@ -15,15 +16,12 @@
import org.openrewrite.java.JavaTemplate;
import org.openrewrite.java.search.FindImports;
import org.openrewrite.java.search.UsesType;
import org.openrewrite.java.tree.Expression;
import org.openrewrite.java.tree.J;
import org.openrewrite.java.tree.TypeUtils;
import org.openrewrite.java.tree.*;
import org.openrewrite.marker.Markers;
import org.philzen.oss.utils.Class;
import org.philzen.oss.utils.*;

import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.*;

@Value
@NonNullApi
Expand Down Expand Up @@ -54,6 +52,16 @@ public TreeVisitor<?, ExecutionContext> getVisitor() {
public static final String JUPITER_API_NAMESPACE = "org.junit.jupiter.api";
public static final String JUPITER_TYPE = JUPITER_API_NAMESPACE + ".Test";
public static final String JUPITER_ASSERTIONS_TYPE = JUPITER_API_NAMESPACE + ".Assertions";

public static final String DESCRIPTION = "description";
public static final String ENABLED = "enabled";
public static final String EXPECTED_EXCEPTIONS = "expectedExceptions";
public static final String EXPECTED_EXCEPTIONS_MSG_REG_EXP = "expectedExceptionsMessageRegExp";
public static final String GROUPS = "groups";
public static final String TIMEOUT = "timeOut";
public static final Set<String> supportedAttributes = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
DESCRIPTION, ENABLED, EXPECTED_EXCEPTIONS, EXPECTED_EXCEPTIONS_MSG_REG_EXP, GROUPS, TIMEOUT
)));

// inspired by https://github.com/openrewrite/rewrite-testing-frameworks/blob/4e8ba68b2a28a180f84de7bab9eb12b4643e342e/src/main/java/org/openrewrite/java/testing/junit5/UpdateTestAnnotation.java#
private static class UpdateTestAnnotationToJunit5Visitor extends JavaIsoVisitor<ExecutionContext> {
Expand Down Expand Up @@ -86,45 +94,54 @@ private static class UpdateTestAnnotationToJunit5Visitor extends JavaIsoVisitor<

@Override
public J.CompilationUnit visitCompilationUnit(J.CompilationUnit cu, ExecutionContext ctx) {
J.CompilationUnit c = super.visitCompilationUnit(cu, ctx);
if (!c.findType(TESTNG_TYPE).isEmpty()) {
// Update other references like `Test.class`.
c = (J.CompilationUnit) new ChangeType(TESTNG_TYPE, JUPITER_TYPE, true)
.getVisitor().visitNonNull(c, ctx);
maybeRemoveImport(TESTNG_TYPE);
cu = super.visitCompilationUnit(cu, ctx);
if (cu.findType(TESTNG_TYPE).isEmpty()) {
return cu;
}

return c;
maybeRemoveImport(TESTNG_TYPE);
return (J.CompilationUnit)
new ChangeType(TESTNG_TYPE, JUPITER_TYPE, true).getVisitor().visitNonNull(cu, ctx);
}

@Override
public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl,
ExecutionContext executionContext) {

final J.Annotation testAnnotation = Class.getAnnotation(classDecl, TESTNG_TEST);
if (testAnnotation != null) {
classDecl = Cleanup.removeAnnotation(classDecl, testAnnotation);
public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, ExecutionContext ctx) {
final J.Annotation testngAnnotation = Class.getAnnotation(classDecl, TESTNG_TEST);
if (testngAnnotation != null) {
final AnnotationVisitor av = new AnnotationVisitor(Collections.emptySet());
av.visitAnnotation(testngAnnotation, ctx);
if (av.misfit != null) {
classDecl = autoFormat(
classDecl.withLeadingAnnotations(ListUtils.concat(classDecl.getLeadingAnnotations(), av.misfit)),
ctx
);
}

classDecl = Cleanup.removeAnnotation(classDecl, testngAnnotation);
getCursor().putMessage(
// don't know a good way to determine if annotation is fully qualified, therefore determining
// it from the toString() method and passing on a code template for the JavaTemplate.Builder
"ADD_TO_ALL_METHODS", "@" + (testAnnotation.toString().contains(".") ? JUPITER_TYPE : "Test")
"ADD_TO_ALL_METHODS", "@" + (testngAnnotation.toString().contains(".") ? JUPITER_TYPE : "Test")
);
}

return super.visitClassDeclaration(classDecl, executionContext);
return super.visitClassDeclaration(classDecl, ctx);
}

@Override
public J.MethodDeclaration visitMethodDeclaration(J.MethodDeclaration method, ExecutionContext ctx) {
final ProcessAnnotationAttributes cta = new ProcessAnnotationAttributes();
J.MethodDeclaration m = (J.MethodDeclaration) cta.visitNonNull(method, ctx, getCursor().getParentOrThrow());

// method identity changes when `@Test` annotation was found and migrated by ChangeTestAnnotation
if (m == method) {
public J.MethodDeclaration visitMethodDeclaration(J.MethodDeclaration m, ExecutionContext ctx) {
final AnnotationVisitor av = new AnnotationVisitor(supportedAttributes);
m = (J.MethodDeclaration) av.visitNonNull(m, ctx, getCursor().getParentOrThrow());

if (av.misfit != null) {
// add the non-migratable TestNG annotation alongside the new JUnit5 annotation
m = autoFormat(m.withLeadingAnnotations(ListUtils.concat(m.getLeadingAnnotations(), av.misfit)), ctx);
}

if (av.parsed.isEmpty()) { // no attributes need to be migrated
final String neededOnAllMethods = getCursor().getNearestMessage("ADD_TO_ALL_METHODS");
final boolean isContainedInInnerClass = Boolean.TRUE.equals(Method.isContainedInInnerClass(m));
if (neededOnAllMethods == null || !Method.isPublic(m) || isContainedInInnerClass || m.isConstructor()) {
if (neededOnAllMethods == null || Method.hasAnnotation(m, neededOnAllMethods) || m.isConstructor()
|| !Method.isPublic(m) || Boolean.TRUE.equals(Method.isContainedInInnerClass(m))) {
return m;
}

Expand All @@ -133,28 +150,33 @@ public J.MethodDeclaration visitMethodDeclaration(J.MethodDeclaration method, Ex
.apply(getCursor(), m.getCoordinates().addAnnotation(Sort.BELOW));
}

if (cta.description != null && !J.Literal.isLiteralValue(cta.description, "")) {
if (av.had(DESCRIPTION) && !J.Literal.isLiteralValue(av.get(DESCRIPTION), "")) {
maybeAddImport(JUPITER_API_NAMESPACE + ".DisplayName");
m = displayNameAnnotation.apply(
updateCursor(m), m.getCoordinates().addAnnotation(Sort.BELOW), cta.description
updateCursor(m), m.getCoordinates().addAnnotation(Sort.BELOW), av.get(DESCRIPTION)
);
}

if (J.Literal.isLiteralValue(cta.enabled, Boolean.FALSE)) {
if (J.Literal.isLiteralValue(av.get(ENABLED), Boolean.FALSE)) {
maybeAddImport(JUPITER_API_NAMESPACE + ".Disabled");
m = disabledAnnotation.apply(updateCursor(m), m.getCoordinates().addAnnotation(Sort.BELOW));
}

if (cta.expectedException instanceof J.FieldAccess
final Expression expectedExceptionsValue = av.get(EXPECTED_EXCEPTIONS);
final Expression firstExpectedException = (expectedExceptionsValue instanceof J.NewArray)
// if attribute was given in { array form }, pick the first element (null is not allowed)
? Objects.requireNonNull(((J.NewArray) expectedExceptionsValue).getInitializer()).get(0)
: expectedExceptionsValue;
if (firstExpectedException instanceof J.FieldAccess
// TestNG actually allows any type of Class here, however anything but a Throwable doesn't make sense
&& TypeUtils.isAssignableTo("java.lang.Throwable", ((J.FieldAccess) cta.expectedException).getTarget().getType()))
&& TypeUtils.isAssignableTo("java.lang.Throwable", ((J.FieldAccess) firstExpectedException).getTarget().getType()))
{
m = junitExecutable.apply(updateCursor(m), m.getCoordinates().replaceBody(), m.getBody());

maybeAddImport(JUPITER_ASSERTIONS_TYPE);
final List<Object> parameters = Arrays.asList(cta.expectedException, Method.getFirstStatementLambdaAssignment(m));
final List<Object> parameters = Arrays.asList(firstExpectedException, Method.getFirstStatementLambdaAssignment(m));
final String code = "Assertions.assertThrows(#{any(java.lang.Class)}, #{any(org.junit.jupiter.api.function.Executable)});";
if (!(cta.expectedExceptionMessageRegExp instanceof J.Literal)) {
if (!(av.get(EXPECTED_EXCEPTIONS_MSG_REG_EXP) instanceof J.Literal)) {
m = JavaTemplate.builder(code).javaParser(Parser.jupiter())
.imports(JUPITER_ASSERTIONS_TYPE).build()
.apply(updateCursor(m), m.getCoordinates().replaceBody(), parameters.toArray());
Expand All @@ -166,69 +188,104 @@ public J.MethodDeclaration visitMethodDeclaration(J.MethodDeclaration method, Ex
.apply(
updateCursor(m),
m.getCoordinates().replaceBody(),
ListUtils.concat(parameters, cta.expectedExceptionMessageRegExp).toArray()
ListUtils.concat(parameters, av.get(EXPECTED_EXCEPTIONS_MSG_REG_EXP)).toArray()
);
}
}

if (cta.groups != null) {
if (av.had(GROUPS)) {
final Expression groupsValue = av.get(GROUPS);
maybeAddImport(JUPITER_API_NAMESPACE + ".Tag");
if (cta.groups instanceof J.Literal && !J.Literal.isLiteralValue(cta.groups, "")) {
m = tagAnnotation.apply(updateCursor(m), m.getCoordinates().addAnnotation(Sort.BELOW), cta.groups);
} else if (cta.groups instanceof J.NewArray && ((J.NewArray) cta.groups).getInitializer() != null) {
final List<Expression> groups = ((J.NewArray) cta.groups).getInitializer();
if (groupsValue instanceof J.Literal && !J.Literal.isLiteralValue(groupsValue, "")) {
m = tagAnnotation.apply(updateCursor(m), m.getCoordinates().addAnnotation(Sort.BELOW), groupsValue);
} else if (groupsValue instanceof J.NewArray && ((J.NewArray) groupsValue).getInitializer() != null) {
final List<Expression> groups = ((J.NewArray) groupsValue).getInitializer();
for (Expression group : groups) {
if (group instanceof J.Empty) continue;
if (group instanceof J.Empty) {
continue;
}
m = tagAnnotation.apply(updateCursor(m), m.getCoordinates().addAnnotation(Sort.BELOW), group);
}
}
}

if (cta.timeout != null) {
if (av.had(TIMEOUT)) {
maybeAddImport("java.util.concurrent.TimeUnit");
maybeAddImport(JUPITER_API_NAMESPACE + ".Timeout");
m = timeoutAnnotation.apply(updateCursor(m), m.getCoordinates().addAnnotation(Sort.ABOVE), cta.timeout);
m = timeoutAnnotation.apply(updateCursor(m), m.getCoordinates().addAnnotation(Sort.ABOVE), av.get(TIMEOUT));
}

return m;
}

/**
* Parses all annotation arguments, retains all that are migratable
* and removes them from the visited <code>@Test</code>-annotation
* Parses annotation arguments, stores those that are migratable in a map (member <code>parsed</code>)
* and removes all arguments from the visited <code>@Test</code>-annotation.
* <br>
* The {@link AnnotationVisitor#misfit}-field will hold a fully qualified NgUnit @Test annotation
* retaining any arguments that are not migratable, if any were encountered.
*/
private static class ProcessAnnotationAttributes extends JavaIsoVisitor<ExecutionContext> {
@RequiredArgsConstructor
private static class AnnotationVisitor extends JavaIsoVisitor<ExecutionContext> {

private final Set<String> supportedAttributes;

/**
* A fully qualified TestNG @Test annotation retaining any arguments that are not migratable
* or <code>null</code>, if none such arguments were encountered
*/
J.Annotation misfit = null;

static final String MISFIT_COMMENT = " ❗\uFE0F\uFE0F\uFE0F\n"
+ " At least one `@Test`-attribute could not be migrated to JUnit 5. Kindly review the remainder below\n"
+ " and manually apply any changes you may require to retain the existing test suite's behavior. Delete\n"
+ "↓ the annotation and this comment when satisfied, or use `git reset --hard` to roll back the migration.\n\n"
+ " If you think this is a mistake or have an idea how this migration could be implemented instead, any\n"
+ " feedback to https://github.com/Philzen/rewrite-TestNG-to-JUnit5/issues will be greatly appreciated.\n";

/**
* A map containing the migratable annotation arguments, if TestNG annotation was found
*/
final Map<String, Expression> parsed = new HashMap<>(6, 100);

public boolean had(String attribute) {
return parsed.containsKey(attribute);
}

@Nullable
Expression description, enabled, expectedException, expectedExceptionMessageRegExp, groups, timeout;

public Expression get(String attribute) {
return parsed.get(attribute);
}

@Override
public J.Annotation visitAnnotation(J.Annotation a, ExecutionContext ctx) {
if (a.getArguments() == null || !TESTNG_TEST.matches(a)) {
final List<Expression> arguments = a.getArguments();
if (arguments == null || !TESTNG_TEST.matches(a)) {
return a;
}

for (Expression arg : a.getArguments()) {
final List<Expression> misfitAttributes = new ArrayList<>(arguments.size());
for (Expression arg : arguments) {
final J.Assignment assign = (J.Assignment) arg;
final String assignParamName = ((J.Identifier) assign.getVariable()).getSimpleName();
final Expression e = assign.getAssignment();
if ("description".equals(assignParamName)) {
description = e;
} else if ("enabled".equals(assignParamName)) {
enabled = e;
} else if ("expectedExceptions".equals(assignParamName)) {
// if attribute was given in { array form }, pick the first element (null is not allowed)
expectedException = !(e instanceof J.NewArray)
? e : Objects.requireNonNull(((J.NewArray) e).getInitializer()).get(0);
} else if ("expectedExceptionsMessageRegExp".equals(assignParamName)) {
expectedExceptionMessageRegExp = e;
} else if ("groups".equals(assignParamName)) {
groups = e;
} else if ("timeOut".equals(assignParamName)) {
timeout = e;
if (supportedAttributes.contains(assignParamName)) {
parsed.put(assignParamName, e);
} else {
misfitAttributes.add(arg);
}
}

if (!misfitAttributes.isEmpty()) {
misfit = a.withArguments(misfitAttributes)
// ↓ change to full qualification
.withAnnotationType(((J.Identifier) a.getAnnotationType()).withSimpleName(TESTNG_TYPE))
.withPrefix(Space.build("\n", Collections.emptyList()))
.withComments(Collections.singletonList(
new TextComment(true, MISFIT_COMMENT, "\n", Markers.EMPTY))
);
}

// remove all attribute arguments (JUnit 5 @Test annotation doesn't allow any)
return a.withArguments(null);
}
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/org/philzen/oss/utils/Method.java
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,8 @@ public static J.Lambda getFirstStatementLambdaAssignment(J.MethodDeclaration met

return (J.Lambda) ((J.VariableDeclarations) body.getStatements().get(0)).getVariables().get(0).getInitializer();
}

public static boolean hasAnnotation(J.MethodDeclaration method, String literal) {
return method.getLeadingAnnotations().stream().anyMatch(annotation -> annotation.toString().equals(literal));
}
}
Loading

0 comments on commit 6abd88d

Please sign in to comment.