diff --git a/java-extension/com.microsoft.java.test.plugin/META-INF/MANIFEST.MF b/java-extension/com.microsoft.java.test.plugin/META-INF/MANIFEST.MF index 14a3ed0d..a873ff2a 100644 --- a/java-extension/com.microsoft.java.test.plugin/META-INF/MANIFEST.MF +++ b/java-extension/com.microsoft.java.test.plugin/META-INF/MANIFEST.MF @@ -24,20 +24,7 @@ Require-Bundle: org.eclipse.jdt.core, org.eclipse.jdt.junit.runtime, org.eclipse.jdt.junit4.runtime, org.eclipse.jdt.junit5.runtime, - junit-jupiter-api;bundle-version="5.4.0", - junit-jupiter-engine;bundle-version="5.4.0", - junit-jupiter-migrationsupport;bundle-version="5.4.0", - junit-jupiter-params;bundle-version="5.4.0", - junit-vintage-engine;bundle-version="5.4.0", - org.opentest4j;bundle-version="1.1.1", - junit-platform-commons;bundle-version="1.4.0", - junit-platform-engine;bundle-version="1.4.0", - junit-platform-launcher;bundle-version="1.4.0", - junit-platform-runner;bundle-version="1.4.0", - junit-platform-suite-api;bundle-version="1.4.0", - junit-platform-suite-commons;bundle-version="1.8.1", - junit-platform-suite-engine;bundle-version="1.8.1", - org.apiguardian.api;bundle-version="1.0.0", + org.eclipse.jdt.junit6.runtime, org.apache.commons.lang3;bundle-version="3.1.0", com.google.gson;bundle-version="2.7.0", org.objectweb.asm;bundle-version="[9.9.0,9.10.0)", diff --git a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java index d0910410..2016ad38 100644 --- a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java +++ b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java @@ -20,9 +20,11 @@ import org.apache.commons.lang3.StringUtils; import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.FileLocator; import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.IStatus; import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.core.runtime.Platform; import org.eclipse.core.runtime.Status; import org.eclipse.debug.core.ILaunch; import org.eclipse.debug.core.ILaunchConfiguration; @@ -37,6 +39,7 @@ import org.eclipse.jdt.core.dom.SingleVariableDeclaration; import org.eclipse.jdt.internal.corext.refactoring.structure.ASTNodeSearchUtil; import org.eclipse.jdt.launching.VMRunnerConfiguration; +import org.osgi.framework.Bundle; import java.io.BufferedWriter; import java.io.File; @@ -45,13 +48,17 @@ import java.io.OutputStreamWriter; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.LinkedList; import java.util.List; +import java.util.Optional; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public class JUnitLaunchConfigurationDelegate extends org.eclipse.jdt.junit.launcher.JUnitLaunchConfigurationDelegate { @@ -60,6 +67,30 @@ public class JUnitLaunchConfigurationDelegate extends org.eclipse.jdt.junit.laun private static final Set testNameArgs = new HashSet<>( Arrays.asList("-test", "-classNames", "-packageNameFile", "-testNameFile")); + // Pattern to match junit-platform-* and junit-jupiter-* jars with version numbers + // Supports both Maven format (junit-jupiter-api-6.0.0.jar) and + // OSGi bundle format (junit-jupiter-api_6.0.0.jar) + private static final Pattern JUNIT_VERSION_PATTERN = Pattern.compile( + "(junit-platform-[a-z-]+|junit-jupiter-[a-z-]+)[-_](\\d+)\\.(\\d+)\\.(\\d+)\\.jar$"); + + // JUnit bundle names that need to be injected for test execution + private static final String[] JUNIT_BUNDLE_NAMES = { + "junit-jupiter-api", + "junit-jupiter-engine", + "junit-jupiter-params", + "junit-platform-commons", + "junit-platform-engine", + "junit-platform-launcher", + "junit-platform-suite-api", + "junit-platform-suite-engine" + }; + + // Common dependency bundle names + private static final String[] COMMON_BUNDLE_NAMES = { + "org.opentest4j", + "org.apiguardian.api" + }; + public JUnitLaunchConfigurationDelegate(Argument args) { super(); this.args = args; @@ -82,8 +113,24 @@ public Response getJUnitLaunchArguments(ILaunchConfigurati launchArguments.workingDirectory = config.getWorkingDirectory(); launchArguments.mainClass = config.getClassToLaunch(); launchArguments.projectName = javaProject.getProject().getName(); - launchArguments.classpath = config.getClassPath(); - launchArguments.modulepath = config.getModulepath(); + + // Debug: Log original classpath + JUnitPlugin.logInfo("[JUnit Launch] TestKind: " + this.args.testKind); + JUnitPlugin.logInfo("[JUnit Launch] Original classpath entries: " + + (config.getClassPath() != null ? config.getClassPath().length : 0)); + if (config.getClassPath() != null) { + for (final String cp : config.getClassPath()) { + JUnitPlugin.logInfo("[JUnit Launch] Classpath: " + cp); + } + } + + launchArguments.classpath = filterClasspathByTestKind(config.getClassPath(), this.args.testKind, true); + launchArguments.modulepath = filterClasspathByTestKind(config.getModulepath(), this.args.testKind, false); + + // Debug: Log filtered classpath + JUnitPlugin.logInfo("[JUnit Launch] Filtered classpath entries: " + + (launchArguments.classpath != null ? launchArguments.classpath.length : 0)); + launchArguments.vmArguments = getVmArguments(config); launchArguments.programArguments = parseParameters(config.getProgramArguments()); @@ -147,7 +194,8 @@ private void addTestItemArgs(List arguments) throws CoreException { arguments.add("-test"); final IMethod method = (IMethod) JavaCore.create(this.args.testNames[0]); String testName = method.getElementName(); - if (this.args.testKind == TestKind.JUnit5 && method.getParameters().length > 0) { + if ((this.args.testKind == TestKind.JUnit5 || this.args.testKind == TestKind.JUnit6) && + method.getParameters().length > 0) { final ICompilationUnit unit = method.getCompilationUnit(); if (unit == null) { throw new CoreException(new Status(IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR, @@ -206,4 +254,223 @@ private String createTestNamesFile(String[] testNames) throws CoreException { IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR, "", e)); //$NON-NLS-1$ } } + + /** + * Check if the given major version is compatible with the test kind. + * For JUnit 5: version < 6 (1.x for platform, 5.x for jupiter) + * For JUnit 6: version >= 6 + * + * @param majorVersion the major version number + * @param testKind the test framework kind + * @return true if compatible, false otherwise + */ + private boolean isVersionCompatible(int majorVersion, TestKind testKind) { + switch (testKind) { + case JUnit5: + return majorVersion < 6; + case JUnit6: + return majorVersion >= 6; + default: + return true; + } + } + + /** + * Filter the classpath/modulepath based on the test kind to avoid version conflicts. + * For JUnit 5: use junit-platform-* version 1.x and junit-jupiter-* version 5.x + * For JUnit 6: use junit-platform-* version 6.x and junit-jupiter-* version 6.x + * + * Also optionally injects the required JUnit bundles from OSGi into the classpath since + * Eclipse's ClasspathLocalizer only adds the junit*runtime bundle but not its Require-Bundle deps. + * + * @param paths the original classpath or modulepath array + * @param testKind the test framework kind + * @param injectBundles whether to inject missing JUnit bundles (should only be true for classpath) + * @return filtered paths array with JUnit bundles optionally injected + */ + private String[] filterClasspathByTestKind(String[] paths, TestKind testKind, boolean injectBundles) { + if (paths == null || testKind == null) { + return paths; + } + + // For non-JUnit5/6 test kinds, no filtering needed + if (testKind != TestKind.JUnit5 && testKind != TestKind.JUnit6) { + return paths; + } + + final List filteredPaths = new ArrayList<>(); + final Set foundJUnitArtifacts = new HashSet<>(); + // Track essential artifacts for incomplete dependency detection + boolean hasApi = false; + boolean hasEngine = false; + boolean hasLauncher = false; + int includedCount = 0; + int excludedCount = 0; + + // Single pass: filter paths and detect incomplete dependencies simultaneously + for (final String path : paths) { + final String fileName = new File(path).getName(); + final Matcher matcher = JUNIT_VERSION_PATTERN.matcher(fileName); + + if (!matcher.find()) { + // Not a junit-platform/jupiter jar, include it directly + filteredPaths.add(path); + continue; + } + + final String artifactName = matcher.group(1); + final int majorVersion = Integer.parseInt(matcher.group(2)); + + // Track essential artifacts for bundle injection decision + if (injectBundles) { + if ("junit-jupiter-api".equals(artifactName)) { + hasApi = true; + } else if ("junit-jupiter-engine".equals(artifactName)) { + hasEngine = true; + } else if ("junit-platform-launcher".equals(artifactName)) { + hasLauncher = true; + } + } + + if (isVersionCompatible(majorVersion, testKind)) { + filteredPaths.add(path); + foundJUnitArtifacts.add(artifactName); + includedCount++; + } else { + excludedCount++; + JUnitPlugin.logInfo("[Classpath Filter] " + testKind + " - Excluding: " + fileName); + } + } + + // Handle incomplete JUnit dependencies: if project has API but missing engine/launcher, + // remove all project JUnit jars and use OSGi bundles for version consistency + final boolean needsFullBundleInjection = injectBundles && hasApi && (!hasEngine || !hasLauncher); + if (needsFullBundleInjection) { + JUnitPlugin.logInfo("[Classpath Filter] Project has incomplete JUnit dependencies, " + + "will use OSGi bundles for all JUnit components to ensure version consistency"); + // Remove all JUnit jars that were added + filteredPaths.removeIf(p -> JUNIT_VERSION_PATTERN.matcher(new File(p).getName()).find()); + foundJUnitArtifacts.clear(); + excludedCount += includedCount; + includedCount = 0; + } + + // Inject JUnit bundles from OSGi if needed + if (injectBundles) { + final List injectedPaths = injectMissingJUnitBundles(testKind, foundJUnitArtifacts); + if (!injectedPaths.isEmpty()) { + filteredPaths.addAll(injectedPaths); + JUnitPlugin.logInfo("[Classpath Inject] Injected " + injectedPaths.size() + + " JUnit bundles for " + testKind); + } + } + + JUnitPlugin.logInfo("[Classpath Filter] TestKind=" + testKind + ", Included=" + + includedCount + " JUnit jars, Excluded=" + excludedCount + + " JUnit jars, Found artifacts=" + foundJUnitArtifacts + + ", Total paths=" + filteredPaths.size()); + + return filteredPaths.toArray(new String[0]); + } + + /** + * Inject missing JUnit bundles from OSGi runtime into the classpath. + * User's project typically only declares junit-jupiter-api as dependency, + * but test execution needs engine, launcher, and other runtime bundles. + * + * @param testKind the test framework kind + * @param foundArtifacts set of JUnit artifact names already in classpath + * @return list of bundle paths to add to classpath + */ + private List injectMissingJUnitBundles(TestKind testKind, Set foundArtifacts) { + final List bundlePaths = new ArrayList<>(); + + // Inject JUnit bundles that are not already in classpath + for (final String bundleName : JUNIT_BUNDLE_NAMES) { + if (!foundArtifacts.contains(bundleName)) { + injectBundle(bundleName, getVersionRange(bundleName, testKind), bundlePaths); + } + } + + // Inject common dependencies if not present (check once, not per bundle) + final boolean hasCommonDeps = foundArtifacts.stream() + .anyMatch(a -> a.contains("opentest4j") || a.contains("apiguardian")); + + if (!hasCommonDeps) { + for (final String bundleName : COMMON_BUNDLE_NAMES) { + injectBundle(bundleName, getVersionRange(bundleName, testKind), bundlePaths); + } + } + + return bundlePaths; + } + + /** + * Get the OSGi version range for a bundle based on test kind. + * + * @param bundleName the bundle symbolic name + * @param testKind the test framework kind + * @return the OSGi version range string + */ + private String getVersionRange(String bundleName, TestKind testKind) { + if (bundleName.startsWith("junit-jupiter")) { + return testKind == TestKind.JUnit5 ? "[5.0.0,6.0.0)" : "[6.0.0,7.0.0)"; + } else if (bundleName.startsWith("junit-platform")) { + return testKind == TestKind.JUnit5 ? "[1.0.0,2.0.0)" : "[6.0.0,7.0.0)"; + } else if ("org.opentest4j".equals(bundleName)) { + return "[1.0.0,3.0.0)"; + } else if ("org.apiguardian.api".equals(bundleName)) { + return "[1.0.0,2.0.0)"; + } + return null; + } + + /** + * Try to inject a bundle into the classpath. + * + * @param bundleName the bundle symbolic name + * @param versionRange the OSGi version range + * @param bundlePaths the list to add the bundle path to + */ + private void injectBundle(String bundleName, String versionRange, List bundlePaths) { + if (versionRange == null) { + return; + } + final String bundlePath = getBundlePath(bundleName, versionRange); + if (bundlePath != null) { + bundlePaths.add(bundlePath); + JUnitPlugin.logInfo("[Classpath Inject] Added bundle: " + bundleName + " -> " + bundlePath); + } else { + JUnitPlugin.logInfo("[Classpath Inject] Bundle not found: " + bundleName + " " + versionRange); + } + } + + /** + * Get the file path for an OSGi bundle by its symbolic name and version range. + * + * @param bundleName the bundle symbolic name + * @param versionRange the OSGi version range string + * @return the absolute file path to the bundle, or null if not found + */ + private String getBundlePath(String bundleName, String versionRange) { + final Bundle[] bundles = Platform.getBundles(bundleName, versionRange); + if (bundles != null && bundles.length > 0) { + final Bundle bundle = bundles[0]; + try { + final Optional bundleFile = FileLocator.getBundleFileLocation(bundle); + if (bundleFile.isPresent()) { + return bundleFile.get().getAbsolutePath(); + } + // Fallback: try to resolve via bundle entry + final URL bundleUrl = bundle.getEntry("/"); + if (bundleUrl != null) { + final URL fileUrl = FileLocator.toFileURL(bundleUrl); + return new File(fileUrl.getPath()).getAbsolutePath(); + } + } catch (IOException e) { + JUnitPlugin.logException("Failed to get bundle path for " + bundleName, e); + } + } + return null; + } } diff --git a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchUtils.java b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchUtils.java index b2aa18f1..a22a2b35 100644 --- a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchUtils.java +++ b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchUtils.java @@ -48,6 +48,7 @@ public class JUnitLaunchUtils { private static final String TESTNG_LOADER = "com.microsoft.java.test.loader.testng"; private static final String JUNIT5_LOADER = "org.eclipse.jdt.junit.loader.junit5"; + private static final String JUNIT6_LOADER = "org.eclipse.jdt.junit.loader.junit6"; private static final String JUNIT4_LOADER = "org.eclipse.jdt.junit.loader.junit4"; private JUnitLaunchUtils() {} @@ -205,6 +206,8 @@ private static String getEclipseTestKind(TestKind testKind) { return JUNIT4_LOADER; case JUnit5: return JUNIT5_LOADER; + case JUnit6: + return JUNIT6_LOADER; case TestNG: return TESTNG_LOADER; default: diff --git a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/model/TestKind.java b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/model/TestKind.java index 5bdd5087..5fc33a6f 100644 --- a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/model/TestKind.java +++ b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/model/TestKind.java @@ -24,6 +24,9 @@ public enum TestKind { @SerializedName("2") TestNG(2), + @SerializedName("3") + JUnit6(3), + @SerializedName("100") None(100); @@ -37,6 +40,8 @@ public static TestKind fromString(String s) { return JUnit; case "JUnit 5": return JUnit5; + case "JUnit 6": + return JUnit6; case "TestNG": return TestNG; default: @@ -53,6 +58,8 @@ public String toString() { return "JUnit 4"; case JUnit5: return "JUnit 5"; + case JUnit6: + return "JUnit 6"; case TestNG: return "TestNG"; default: diff --git a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/provider/TestKindProvider.java b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/provider/TestKindProvider.java index af89d11f..96020ec8 100644 --- a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/provider/TestKindProvider.java +++ b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/provider/TestKindProvider.java @@ -14,13 +14,20 @@ import com.microsoft.java.test.plugin.model.TestKind; import com.microsoft.java.test.plugin.util.JUnitPlugin; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.jdt.core.IClasspathEntry; import org.eclipse.jdt.core.IJavaProject; import org.eclipse.jdt.core.JavaModelException; +import org.eclipse.jdt.launching.IVMInstall; +import org.eclipse.jdt.launching.IVMInstall2; +import org.eclipse.jdt.launching.JavaRuntime; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public class TestKindProvider { private static Map> map = new HashMap<>(); @@ -45,7 +52,11 @@ private static List getTestKinds(IJavaProject javaProject) { final List result = new LinkedList<>(); try { if (javaProject.findType(JUNIT5_TEST) != null) { - result.add(TestKind.JUnit5); + if (isJUnit6(javaProject)) { + result.add(TestKind.JUnit6); + } else { + result.add(TestKind.JUnit5); + } } if (javaProject.findType(JUNIT4_TEST) != null) { @@ -60,4 +71,61 @@ private static List getTestKinds(IJavaProject javaProject) { } return result; } + + private static boolean isJUnit6(IJavaProject project) { + if (!isJava17OrHigher(project)) { + return false; + } + return isJUnitJupiterApiVersion6OrHigher(project); + } + + private static boolean isJava17OrHigher(IJavaProject project) { + try { + final IVMInstall vm = JavaRuntime.getVMInstall(project); + if (vm instanceof IVMInstall2) { + final String javaVersion = ((IVMInstall2) vm).getJavaVersion(); + if (javaVersion != null) { + return getMajorVersion(javaVersion) >= 17; + } + } + } catch (CoreException e) { + // ignore + } + return false; + } + + private static int getMajorVersion(String version) { + if (version.startsWith("1.")) { + return Integer.parseInt(version.substring(2, 3)); + } + final int dot = version.indexOf('.'); + if (dot != -1) { + return Integer.parseInt(version.substring(0, dot)); + } + try { + return Integer.parseInt(version); + } catch (NumberFormatException e) { + return 0; + } + } + + private static boolean isJUnitJupiterApiVersion6OrHigher(IJavaProject project) { + try { + for (final IClasspathEntry entry : project.getResolvedClasspath(true)) { + if (entry.getPath().lastSegment().contains("junit-jupiter-api")) { + final String fileName = entry.getPath().lastSegment(); + final Matcher m = Pattern.compile("junit-jupiter-api-(\\d+)\\.").matcher(fileName); + if (m.find()) { + final int major = Integer.parseInt(m.group(1)); + if (major >= 6) { + return true; + } + } + } + } + } catch (JavaModelException e) { + // ignore + } + return false; + } } diff --git a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/searcher/JUnit6TestSearcher.java b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/searcher/JUnit6TestSearcher.java new file mode 100644 index 00000000..ae3f0c62 --- /dev/null +++ b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/searcher/JUnit6TestSearcher.java @@ -0,0 +1,165 @@ +/******************************************************************************* + * Copyright (c) 2017-2025 Microsoft Corporation and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Microsoft Corporation - initial API and implementation + *******************************************************************************/ + +package com.microsoft.java.test.plugin.searcher; + +import com.microsoft.java.test.plugin.model.TestKind; +import com.microsoft.java.test.plugin.util.TestFrameworkUtils; + +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.OperationCanceledException; +import org.eclipse.jdt.core.IJavaElement; +import org.eclipse.jdt.core.IType; +import org.eclipse.jdt.core.JavaModelException; +import org.eclipse.jdt.core.dom.IAnnotationBinding; +import org.eclipse.jdt.core.dom.IMethodBinding; +import org.eclipse.jdt.core.dom.ITypeBinding; +import org.eclipse.jdt.core.dom.Modifier; +import org.eclipse.jdt.internal.junit.launcher.JUnit5TestFinder; +import org.eclipse.jdt.internal.junit.launcher.TestKindRegistry; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Test searcher for JUnit 6 (Jupiter API 6.x). + * + * Key differences from JUnit 5: + * - Uses standard @Test annotation directly (org.junit.jupiter.api.Test) + * - Does not rely on @Testable meta-annotation + * - Still supports @Nested for nested test classes + */ +public class JUnit6TestSearcher extends BaseFrameworkSearcher { + + private static final JUnit5TestFinder JUNIT5_TEST_FINDER = new JUnit5TestFinder(); + + // Standard JUnit Jupiter annotations + public static final String JUPITER_TEST = "org.junit.jupiter.api.Test"; + public static final String JUPITER_PARAMETERIZED_TEST = "org.junit.jupiter.api.params.ParameterizedTest"; + public static final String JUPITER_REPEATED_TEST = "org.junit.jupiter.api.RepeatedTest"; + public static final String JUPITER_TEST_FACTORY = "org.junit.jupiter.api.TestFactory"; + public static final String JUPITER_TEST_TEMPLATE = "org.junit.jupiter.api.TestTemplate"; + public static final String JUPITER_NESTED = "org.junit.jupiter.api.Nested"; + + protected static final String DISPLAY_NAME_ANNOTATION_JUNIT6 = "org.junit.jupiter.api.DisplayName"; + + public JUnit6TestSearcher() { + super(); + // JUnit 6 uses standard test annotations, not @Testable + this.testMethodAnnotations = new String[] { + JUPITER_TEST, + JUPITER_PARAMETERIZED_TEST, + JUPITER_REPEATED_TEST, + JUPITER_TEST_FACTORY, + JUPITER_TEST_TEMPLATE + }; + } + + @Override + public TestKind getTestKind() { + return TestKind.JUnit6; + } + + @Override + public String getJdtTestKind() { + // JUnit 6 uses the same JDT test kind as JUnit 5 + return TestKindRegistry.JUNIT6_TEST_KIND_ID; + } + + @Override + public boolean isTestMethod(IMethodBinding methodBinding) { + final int modifiers = methodBinding.getModifiers(); + if (Modifier.isAbstract(modifiers) || Modifier.isStatic(modifiers) || Modifier.isPrivate(modifiers)) { + return false; + } + + if (methodBinding.isConstructor()) { + return false; + } + + // Check for standard JUnit test annotations directly + return this.findAnnotation(methodBinding.getAnnotations(), this.getTestMethodAnnotations()); + } + + @Override + public boolean findAnnotation(IAnnotationBinding[] annotations, String[] annotationNames) { + for (final IAnnotationBinding annotation : annotations) { + if (annotation == null) { + continue; + } + for (final String annotationName : annotationNames) { + if (matchesName(annotation.getAnnotationType(), annotationName)) { + return true; + } + + // Special handling for @Nested to check annotation hierarchy + if (JUPITER_NESTED.equals(annotationName)) { + final Set hierarchy = new HashSet<>(); + if (matchesNameInAnnotationHierarchy(annotation, annotationName, hierarchy)) { + return true; + } + } + } + } + return false; + } + + @Override + public boolean isTestClass(IType type) throws JavaModelException { + // Reuse JUnit 5 test finder as it works for JUnit 6 as well + return JUNIT5_TEST_FINDER.isTest(type); + } + + private boolean matchesName(ITypeBinding annotationType, String annotationName) { + return TestFrameworkUtils.isEquivalentAnnotationType(annotationType, annotationName); + } + + /** + * Recursively checks the annotation hierarchy to find matching annotations. + * This is useful for meta-annotations like @Nested. + */ + private boolean matchesNameInAnnotationHierarchy(IAnnotationBinding annotation, String annotationName, + Set hierarchy) { + final ITypeBinding type = annotation.getAnnotationType(); + if (type == null) { + return false; + } + + for (final IAnnotationBinding annotationBinding : type.getAnnotations()) { + if (annotationBinding == null) { + continue; + } + final ITypeBinding annotationType = annotationBinding.getAnnotationType(); + if (annotationType != null && hierarchy.add(annotationType)) { + if (matchesName(annotationType, annotationName) || + matchesNameInAnnotationHierarchy(annotationBinding, annotationName, hierarchy)) { + return true; + } + } + } + + return false; + } + + @Override + public Set findTestItemsInContainer(IJavaElement element, IProgressMonitor monitor) throws CoreException { + final Set types = new HashSet<>(); + try { + // Reuse JUnit 5 test finder as the test discovery mechanism is compatible + JUNIT5_TEST_FINDER.findTestsInContainer(element, types, monitor); + } catch (OperationCanceledException e) { + return Collections.emptySet(); + } + return types; + } +} diff --git a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/JUnitPlugin.java b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/JUnitPlugin.java index 1f3231a2..3d72f867 100644 --- a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/JUnitPlugin.java +++ b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/JUnitPlugin.java @@ -19,6 +19,7 @@ import org.eclipse.core.runtime.Platform; import org.eclipse.core.runtime.Status; import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin; +import org.osgi.framework.Bundle; import org.osgi.framework.BundleActivator; import org.osgi.framework.BundleContext; @@ -53,6 +54,8 @@ public class JUnitPlugin implements BundleActivator { public void start(BundleContext context) throws Exception { handler.addElementChangeListener(); JUnitPlugin.context = context; + // Debug: Log JUnit bundle status on startup + logJUnitBundleStatus(); } /* @@ -114,4 +117,46 @@ public static Object askClientForInput(String prompt, String defaultValue) { return JavaLanguageServerPlugin.getInstance().getClientConnection() .executeClientCommand(JAVA_TEST_ASK_CLIENT_FOR_INPUT, prompt, defaultValue); } + + /** + * Debug method to log all installed OSGi bundles related to JUnit. + * Call this method to diagnose bundle resolution issues. + */ + public static void logJUnitBundleStatus() { + if (context == null) { + return; + } + + logInfo("=== OSGi Bundle Status for JUnit ==="); + final Bundle[] bundles = context.getBundles(); + + for (final Bundle bundle : bundles) { + final String symbolicName = bundle.getSymbolicName(); + if (symbolicName != null && ( + symbolicName.contains("junit") || + symbolicName.contains("jupiter") || + symbolicName.contains("opentest4j") || + symbolicName.contains("apiguardian"))) { + + final String stateStr = getBundleStateString(bundle.getState()); + logInfo(String.format("Bundle: %s, Version: %s, State: %s", + symbolicName, + bundle.getVersion().toString(), + stateStr)); + } + } + logInfo("=== End Bundle Status ==="); + } + + private static String getBundleStateString(int state) { + switch (state) { + case Bundle.UNINSTALLED: return "UNINSTALLED"; + case Bundle.INSTALLED: return "INSTALLED"; + case Bundle.RESOLVED: return "RESOLVED"; + case Bundle.STARTING: return "STARTING"; + case Bundle.STOPPING: return "STOPPING"; + case Bundle.ACTIVE: return "ACTIVE"; + default: return "UNKNOWN(" + state + ")"; + } + } } diff --git a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/TestFrameworkUtils.java b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/TestFrameworkUtils.java index 00cc1223..b3e8677e 100644 --- a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/TestFrameworkUtils.java +++ b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/TestFrameworkUtils.java @@ -14,6 +14,7 @@ import com.microsoft.java.test.plugin.model.TestKind; import com.microsoft.java.test.plugin.searcher.JUnit4TestSearcher; import com.microsoft.java.test.plugin.searcher.JUnit5TestSearcher; +import com.microsoft.java.test.plugin.searcher.JUnit6TestSearcher; import com.microsoft.java.test.plugin.searcher.TestFrameworkSearcher; import com.microsoft.java.test.plugin.searcher.TestNGTestSearcher; @@ -25,6 +26,7 @@ public class TestFrameworkUtils { public static final TestFrameworkSearcher JUNIT4_TEST_SEARCHER = new JUnit4TestSearcher(); public static final TestFrameworkSearcher JUNIT5_TEST_SEARCHER = new JUnit5TestSearcher(); + public static final TestFrameworkSearcher JUNIT6_TEST_SEARCHER = new JUnit6TestSearcher(); public static final TestFrameworkSearcher TESTNG_TEST_SEARCHER = new TestNGTestSearcher(); public static boolean isEquivalentAnnotationType(ITypeBinding annotationType, String annotationName) { @@ -37,6 +39,8 @@ public static TestFrameworkSearcher getSearcherByTestKind(TestKind kind) { return JUNIT4_TEST_SEARCHER; case JUnit5: return JUNIT5_TEST_SEARCHER; + case JUnit6: + return JUNIT6_TEST_SEARCHER; case TestNG: return TESTNG_TEST_SEARCHER; default: diff --git a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/TestGenerationUtils.java b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/TestGenerationUtils.java index f75cab3d..c3509134 100644 --- a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/TestGenerationUtils.java +++ b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/TestGenerationUtils.java @@ -764,6 +764,7 @@ private static List getLifecycleAnnotations(TestKind testKind) { list.add(JUNIT4_AFTER_CLASS_ANNOTATION); break; case JUnit5: + case JUnit6: list.add(JUNIT5_BEFORE_CLASS_ANNOTATION); list.add(JUNIT5_SET_UP_ANNOTATION); list.add(JUNIT5_TEAR_DOWN_ANNOTATION); diff --git a/java-extension/com.microsoft.java.test.target/com.microsoft.java.test.tp.target b/java-extension/com.microsoft.java.test.target/com.microsoft.java.test.tp.target index 5d387936..c190d8bf 100644 --- a/java-extension/com.microsoft.java.test.target/com.microsoft.java.test.tp.target +++ b/java-extension/com.microsoft.java.test.target/com.microsoft.java.test.tp.target @@ -4,17 +4,16 @@ - - - + + - + diff --git a/package-lock.json b/package-lock.json index 2e065a15..30187568 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "vscode-java-test", - "version": "0.43.1", + "version": "0.43.2", "dependencies": { "fs-extra": "^10.1.0", "get-port": "^4.2.0", diff --git a/package.json b/package.json index e10303a6..eef210cc 100644 --- a/package.json +++ b/package.json @@ -55,22 +55,31 @@ "main": "./main.js", "contributes": { "javaExtensions": [ - "./server/junit-jupiter-api_5.11.0.jar", - "./server/junit-jupiter-engine_5.11.0.jar", - "./server/junit-jupiter-migrationsupport_5.11.0.jar", - "./server/junit-jupiter-params_5.11.0.jar", - "./server/junit-platform-commons_1.11.0.jar", - "./server/junit-platform-engine_1.11.0.jar", - "./server/junit-platform-launcher_1.11.0.jar", - "./server/junit-platform-runner_1.11.0.jar", - "./server/junit-platform-suite-api_1.11.0.jar", - "./server/junit-platform-suite-commons_1.11.0.jar", - "./server/junit-platform-suite-engine_1.11.0.jar", - "./server/junit-vintage-engine_5.11.0.jar", + "./server/junit-jupiter-api_5.14.1.jar", + "./server/junit-jupiter-api_6.0.1.jar", + "./server/junit-jupiter-engine_5.14.1.jar", + "./server/junit-jupiter-engine_6.0.1.jar", + "./server/junit-jupiter-migrationsupport_5.14.1.jar", + "./server/junit-jupiter-params_5.14.1.jar", + "./server/junit-jupiter-params_6.0.1.jar", + "./server/junit-platform-commons_1.14.1.jar", + "./server/junit-platform-commons_6.0.1.jar", + "./server/junit-platform-engine_1.14.1.jar", + "./server/junit-platform-engine_6.0.1.jar", + "./server/junit-platform-launcher_1.14.1.jar", + "./server/junit-platform-launcher_6.0.1.jar", + "./server/junit-platform-runner_1.14.1.jar", + "./server/junit-platform-suite-api_1.14.1.jar", + "./server/junit-platform-suite-api_6.0.1.jar", + "./server/junit-platform-suite-commons_1.14.1.jar", + "./server/junit-platform-suite-engine_1.14.1.jar", + "./server/junit-platform-suite-engine_6.0.1.jar", + "./server/junit-vintage-engine_5.14.1.jar", "./server/org.apiguardian.api_1.1.2.jar", - "./server/org.eclipse.jdt.junit4.runtime_1.3.100.v20231214-1952.jar", - "./server/org.eclipse.jdt.junit5.runtime_1.1.300.v20231214-1952.jar", "./server/org.opentest4j_1.3.0.jar", + "./server/org.eclipse.jdt.junit4.runtime_1.4.0.v20251113-1434.jar", + "./server/org.eclipse.jdt.junit5.runtime_1.2.0.v20251113-1434.jar", + "./server/org.eclipse.jdt.junit6.runtime_1.0.0.v20251112-1701.jar", "./server/org.jacoco.core_0.8.14.202510111229.jar", "./server/com.microsoft.java.test.plugin-0.43.1.jar" ], diff --git a/scripts/buildJdtlsExt.js b/scripts/buildJdtlsExt.js index 87c29f2d..fe4d32b2 100644 --- a/scripts/buildJdtlsExt.js +++ b/scripts/buildJdtlsExt.js @@ -12,6 +12,10 @@ const serverDir = path.resolve('java-extension'); const bundleList = [ 'org.eclipse.jdt.junit4.runtime_', 'org.eclipse.jdt.junit5.runtime_', + 'org.eclipse.jdt.junit6.runtime_', + 'org.eclipse.jdt.junit.core_', + 'org.eclipse.jdt.junit.runtime_', + 'org.eclipse.core.resources_', 'junit-jupiter-api', 'junit-jupiter-engine', 'junit-jupiter-migrationsupport', @@ -45,19 +49,131 @@ function copy(sourceFolder, targetFolder, fileFilter) { } } +// Helper function to select the JAR version(s) for javaExtensions (OSGi bundles) +// Returns both JUnit 5 (5.x/1.x) and JUnit 6 (6.x) versions for components that need both +// This is required because org.eclipse.jdt.junit5.runtime requires 5.x and +// org.eclipse.jdt.junit6.runtime requires 6.x +function selectJarVersions(files, baseName) { + const matchingFiles = files.filter(file => file.startsWith(baseName + '_')); + if (matchingFiles.length === 0) { + return []; + } + + // Pattern to match version numbers + const junit5Pattern = new RegExp(`^${baseName}_(5\\.\\d+\\.\\d+)\\.jar$`); + const junit6Pattern = new RegExp(`^${baseName}_(6\\.\\d+\\.\\d+)\\.jar$`); + const platform1Pattern = new RegExp(`^${baseName}_(1\\.\\d+\\.\\d+)\\.jar$`); + + // Components that need both JUnit 5 and JUnit 6 versions + const needsBothVersions = [ + 'junit-jupiter-api', + 'junit-jupiter-engine', + 'junit-jupiter-params', + 'junit-platform-commons', + 'junit-platform-engine', + 'junit-platform-launcher', + 'junit-platform-suite-api', + 'junit-platform-suite-engine' + ]; + + if (needsBothVersions.includes(baseName)) { + const result = []; + // Add 5.x version if exists (for junit-jupiter-*) + const version5Files = matchingFiles.filter(file => junit5Pattern.test(file)); + if (version5Files.length > 0) { + result.push(version5Files.sort().reverse()[0]); + } + // Add 1.x version if exists (for junit-platform-*) + const version1Files = matchingFiles.filter(file => platform1Pattern.test(file)); + if (version1Files.length > 0) { + result.push(version1Files.sort().reverse()[0]); + } + // Add 6.x version if exists + const version6Files = matchingFiles.filter(file => junit6Pattern.test(file)); + if (version6Files.length > 0) { + result.push(version6Files.sort().reverse()[0]); + } + // Remove duplicates and return + return [...new Set(result)]; + } + + // For junit-vintage-engine, only use 5.x + if (baseName === 'junit-vintage-engine') { + const version5Files = matchingFiles.filter(file => junit5Pattern.test(file)); + if (version5Files.length > 0) { + return [version5Files.sort().reverse()[0]]; + } + } + + // For junit-jupiter-migrationsupport, only use 5.x (no 6.x version exists) + if (baseName === 'junit-jupiter-migrationsupport') { + const version5Files = matchingFiles.filter(file => junit5Pattern.test(file)); + if (version5Files.length > 0) { + return [version5Files.sort().reverse()[0]]; + } + } + + // For platform-runner and suite-commons, use 1.x version only + if (baseName === 'junit-platform-runner' || baseName === 'junit-platform-suite-commons') { + const platform1Files = matchingFiles.filter(file => platform1Pattern.test(file)); + if (platform1Files.length > 0) { + return [platform1Files.sort().reverse()[0]]; + } + } + + // Default: return the highest version + return [matchingFiles.sort().reverse()[0]]; +} + +// Legacy function for backward compatibility +function selectJarVersion(files, baseName) { + const versions = selectJarVersions(files, baseName); + return versions.length > 0 ? versions[0] : null; +} + function updateVersion() { // Update the version const packageJsonData = require('../package.json'); const javaExtensions = packageJsonData.contributes.javaExtensions; + const destFolder = path.resolve('./server'); + const files = fs.readdirSync(destFolder); + if (Array.isArray(javaExtensions)) { - packageJsonData.contributes.javaExtensions = javaExtensions.map((extensionString) => { + // Track which base names we've already processed to avoid duplicates + const processedBaseNames = new Set(); + const newJavaExtensions = []; + + for (const extensionString of javaExtensions) { const ind = extensionString.indexOf('_'); - const fileName = findNewRequiredJar(extensionString.substring(extensionString.lastIndexOf('/') + 1, ind)); if (ind >= 0) { - return extensionString.substring(0, extensionString.lastIndexOf('/') + 1) + fileName; + const baseName = extensionString.substring(extensionString.lastIndexOf('/') + 1, ind); + + // Skip if we've already processed this base name + if (processedBaseNames.has(baseName)) { + continue; + } + processedBaseNames.add(baseName); + + // Get all versions for this component + const fileNames = selectJarVersions(files, baseName); + if (fileNames.length > 0) { + for (const fileName of fileNames) { + newJavaExtensions.push('./server/' + fileName); + } + } else { + // Fallback to finding any matching jar + const fallbackFileName = findNewRequiredJar(baseName); + if (fallbackFileName) { + newJavaExtensions.push('./server/' + fallbackFileName); + } + } + } else { + // Non-versioned extension (like the plugin jar) + newJavaExtensions.push(extensionString); } - return extensionString; - }); + } + + packageJsonData.contributes.javaExtensions = newJavaExtensions; fs.writeFileSync(path.resolve('package.json'), JSON.stringify(packageJsonData, null, 4)); fs.appendFileSync(path.resolve('package.json'), os.EOL); diff --git a/src/controller/testController.ts b/src/controller/testController.ts index 4242f7ae..4b31d2fe 100644 --- a/src/controller/testController.ts +++ b/src/controller/testController.ts @@ -696,6 +696,7 @@ function getRunnerByContext(testContext: IRunTestContext): BaseRunner | undefine switch (testContext.kind) { case TestKind.JUnit: case TestKind.JUnit5: + case TestKind.JUnit6: return new JUnitRunner(testContext); case TestKind.TestNG: return new TestNGRunner(testContext); @@ -711,6 +712,7 @@ function trackTestFrameworkVersion(testKind: TestKind, classpaths: string[], mod artifactPattern = /junit-(\d+\.\d+\.\d+(-[a-zA-Z\d]+)?).jar/; break; case TestKind.JUnit5: + case TestKind.JUnit6: artifactPattern = /junit-jupiter-api-(\d+\.\d+\.\d+(-[a-zA-Z\d]+)?).jar/; break; case TestKind.TestNG: diff --git a/src/java-test-runner.api.ts b/src/java-test-runner.api.ts index df35283b..7688e5d6 100644 --- a/src/java-test-runner.api.ts +++ b/src/java-test-runner.api.ts @@ -220,6 +220,7 @@ export enum TestKind { JUnit5 = 0, JUnit = 1, TestNG = 2, + JUnit6 = 3, None = 100, } diff --git a/src/runners/junitRunner/JUnitRunnerResultAnalyzer.ts b/src/runners/junitRunner/JUnitRunnerResultAnalyzer.ts index 5a376a2a..e93e6655 100644 --- a/src/runners/junitRunner/JUnitRunnerResultAnalyzer.ts +++ b/src/runners/junitRunner/JUnitRunnerResultAnalyzer.ts @@ -349,7 +349,7 @@ export class JUnitRunnerResultAnalyzer extends RunnerResultAnalyzer { const isDynamic: boolean = result[4] === 'true'; const parentIndex: string = result[5]; const displayName: string = result[6].replace(/\\,/g, ','); - const uniqueId: string | undefined = this.testContext.kind === TestKind.JUnit5 ? + const uniqueId: string | undefined = (this.testContext.kind === TestKind.JUnit5 || this.testContext.kind === TestKind.JUnit6) ? result[8]?.replace(/\\,/g, ',') : undefined; let testItem: TestItem | undefined; @@ -398,7 +398,7 @@ export class JUnitRunnerResultAnalyzer extends RunnerResultAnalyzer { } if (testItem) { - if (dataCache.get(testItem)?.testKind === TestKind.JUnit5 && + if ((dataCache.get(testItem)?.testKind === TestKind.JUnit5 || dataCache.get(testItem)?.testKind === TestKind.JUnit6) && this.getLabelWithoutCodicon(testItem.label) !== displayName) { testItem.description = displayName; } else { diff --git a/src/utils/launchUtils.ts b/src/utils/launchUtils.ts index 53500e20..433b8486 100644 --- a/src/utils/launchUtils.ts +++ b/src/utils/launchUtils.ts @@ -60,7 +60,7 @@ export async function resolveLaunchConfigurationForRunner(runner: BaseRunner, te ], args: [ ...launchArguments.programArguments, - ...(testContext.kind === TestKind.JUnit5 ? parseTags(config) : []) + ...(testContext.kind === TestKind.JUnit5 || testContext.kind === TestKind.JUnit6 ? parseTags(config) : []) ], }); }