TestTimeoutJUnitPlugin.java
/*******************************************************************************
* Copyright (c) 2026 Carsten Hammer.
*
* This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Carsten Hammer
*******************************************************************************/
package org.sandbox.jdt.internal.corext.fix.helper;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.*;
import java.util.List;
import org.eclipse.jdt.core.dom.AST;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.MarkerAnnotation;
import org.eclipse.jdt.core.dom.MemberValuePair;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.NormalAnnotation;
import org.eclipse.jdt.core.dom.NumberLiteral;
import org.eclipse.jdt.core.dom.QualifiedName;
import org.eclipse.jdt.core.dom.rewrite.ASTRewrite;
import org.eclipse.jdt.core.dom.rewrite.ImportRewrite;
import org.eclipse.jdt.core.dom.rewrite.ListRewrite;
import org.eclipse.jdt.internal.corext.dom.ASTNodes;
import org.eclipse.text.edits.TextEditGroup;
import org.sandbox.jdt.internal.corext.util.AnnotationUtils;
import org.sandbox.jdt.internal.corext.fix.helper.lib.JunitHolder;
import org.sandbox.jdt.internal.corext.fix.helper.lib.TriggerPatternCleanupPlugin;
import org.sandbox.jdt.triggerpattern.api.CleanupPattern;
import org.sandbox.jdt.triggerpattern.api.Match;
import org.sandbox.jdt.triggerpattern.api.PatternKind;
/**
* Plugin to migrate JUnit 4 @Test(timeout=...) to JUnit 5 @Timeout.
*
* @since 1.3.0
*/
@CleanupPattern(value = "@Test(timeout=$t)", kind = PatternKind.ANNOTATION, qualifiedType = ORG_JUNIT_TEST, cleanupId = "cleanup.junit.test.timeout", description = "Migrate @Test(timeout=...) to @Timeout", displayName = "JUnit 4 @Test(timeout) → JUnit 5 @Timeout")
public class TestTimeoutJUnitPlugin extends TriggerPatternCleanupPlugin {
@Override
protected JunitHolder createHolder(Match match) {
ASTNode node = match.getMatchedNode();
if (!(node instanceof NormalAnnotation)) {
return null;
}
NormalAnnotation annotation = (NormalAnnotation) node;
MemberValuePair timeoutPair = null;
for (Object obj : annotation.values()) {
MemberValuePair pair = (MemberValuePair) obj;
if ("timeout".equals(pair.getName().getIdentifier())) { //$NON-NLS-1$
timeoutPair = pair;
break;
}
}
if (timeoutPair == null) {
return null;
}
Expression value = timeoutPair.getValue();
if (!(value instanceof NumberLiteral)) {
return null;
}
try {
Long.parseLong(((NumberLiteral) value).getToken());
} catch (NumberFormatException e) {
return null;
}
JunitHolder holder = new JunitHolder();
holder.setMinv(annotation);
holder.setAdditionalInfo(timeoutPair);
return holder;
}
@Override
protected void process2Rewrite(TextEditGroup group, ASTRewrite rewriter, AST ast, ImportRewrite importRewriter,
JunitHolder junitHolder) {
NormalAnnotation testAnnotation = (NormalAnnotation) junitHolder.getAnnotation();
MemberValuePair timeoutPair = (MemberValuePair) junitHolder.getAdditionalInfo();
if (timeoutPair == null) {
return;
}
Expression timeoutValue = timeoutPair.getValue();
if (!(timeoutValue instanceof NumberLiteral)) {
return;
}
final long timeoutMillis;
try {
timeoutMillis = Long.parseLong(((NumberLiteral) timeoutValue).getToken());
} catch (NumberFormatException e) {
// Malformed timeout value, skip refactoring for this method
return;
}
// Determine the best time unit (optimize for readability)
// Use SECONDS if the value is >= 1000ms and evenly divisible by 1000
// This makes the timeout more readable (e.g., "5 seconds" vs "5000
// milliseconds")
long timeout;
String timeUnit;
if (timeoutMillis % 1000 == 0 && timeoutMillis >= 1000) {
// Use SECONDS for better readability (e.g., 1 second instead of 1000
// milliseconds)
timeout = timeoutMillis / 1000;
timeUnit = "SECONDS";
} else {
// Use MILLISECONDS for values < 1000ms or not evenly divisible by 1000
timeout = timeoutMillis;
timeUnit = "MILLISECONDS";
}
// Create @Timeout annotation
NormalAnnotation timeoutAnnotation = ast.newNormalAnnotation();
timeoutAnnotation.setTypeName(ast.newSimpleName(ANNOTATION_TIMEOUT));
// Add value parameter
MemberValuePair valuePair = ast.newMemberValuePair();
valuePair.setName(ast.newSimpleName("value"));
valuePair.setValue(ast.newNumberLiteral(String.valueOf(timeout)));
timeoutAnnotation.values().add(valuePair);
// Add unit parameter
MemberValuePair unitPair = ast.newMemberValuePair();
unitPair.setName(ast.newSimpleName("unit"));
QualifiedName timeUnitName = ast.newQualifiedName(ast.newSimpleName("TimeUnit"), ast.newSimpleName(timeUnit));
unitPair.setValue(timeUnitName);
timeoutAnnotation.values().add(unitPair);
// Add the @Timeout annotation to the method (after @Test)
MethodDeclaration method = ASTNodes.getParent(testAnnotation, MethodDeclaration.class);
if (method != null) {
ListRewrite listRewrite = rewriter.getListRewrite(method, MethodDeclaration.MODIFIERS2_PROPERTY);
listRewrite.insertAfter(timeoutAnnotation, testAnnotation, group);
}
// Remove the timeout parameter from @Test annotation
// If the timeout is the only parameter, replace the NormalAnnotation with a
// MarkerAnnotation
// to avoid leaving an empty @Test() annotation.
List<MemberValuePair> testValues = testAnnotation.values();
if (testValues.size() == 1 && testValues.get(0) == timeoutPair) {
MarkerAnnotation markerTestAnnotation = AnnotationUtils.createMarkerAnnotation(ast, ANNOTATION_TEST);
ASTNodes.replaceButKeepComment(rewriter, testAnnotation, markerTestAnnotation, group);
} else {
rewriter.remove(timeoutPair, group);
}
// Add imports - order matters: remove old import first, then add new imports
importRewriter.removeImport(ORG_JUNIT_TEST);
importRewriter.addImport(ORG_JUNIT_JUPITER_TEST);
importRewriter.addImport(ORG_JUNIT_JUPITER_API_TIMEOUT);
importRewriter.addImport("java.util.concurrent.TimeUnit");
}
@Override
public String getPreview(boolean afterRefactoring) {
if (afterRefactoring) {
return """
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import java.util.concurrent.TimeUnit;
@Test
@Timeout(value = 1, unit = TimeUnit.SECONDS)
public void testWithTimeout() {
// Test code
}
"""; //$NON-NLS-1$
}
return """
import org.junit.Test;
@Test(timeout = 1000)
public void testWithTimeout() {
// Test code
}
"""; //$NON-NLS-1$
}
@Override
public String toString() {
return "TestTimeout"; //$NON-NLS-1$
}
}