Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All JUnit-related bundle dependencies have been removed from Require-Bundle, but the code still references classes from these bundles (e.g., JUnit5TestFinder, TestKindRegistry in JUnit6TestSearcher.java). This will cause ClassNotFoundException at runtime. The bundles should remain in Require-Bundle or the dependencies should be marked as optional. If the intention is to load these dynamically via classpath injection, the code needs to handle missing classes gracefully.

Copilot uses AI. Check for mistakes.
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)",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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 {

Expand All @@ -60,6 +67,30 @@ public class JUnitLaunchConfigurationDelegate extends org.eclipse.jdt.junit.laun
private static final Set<String> 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;
Expand All @@ -82,8 +113,24 @@ public Response<JUnitLaunchArguments> 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());

Expand Down Expand Up @@ -147,7 +194,8 @@ private void addTestItemArgs(List<String> 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) {
Comment on lines +197 to +198
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition (this.args.testKind == TestKind.JUnit5 || this.args.testKind == TestKind.JUnit6) is repeated throughout the codebase. Consider extracting this into a helper method like isJUnit5Or6(TestKind kind) or an enum method like testKind.isJupiterBased() to improve maintainability and reduce duplication.

Copilot uses AI. Check for mistakes.
final ICompilationUnit unit = method.getCompilationUnit();
if (unit == null) {
throw new CoreException(new Status(IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR,
Expand Down Expand Up @@ -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<String> filteredPaths = new ArrayList<>();
final Set<String> 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));
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential uncaught 'java.lang.NumberFormatException'.

Suggested change
final int majorVersion = Integer.parseInt(matcher.group(2));
final String versionGroup = matcher.group(2);
int majorVersion;
try {
majorVersion = Integer.parseInt(versionGroup);
} catch (NumberFormatException e) {
JUnitPlugin.logInfo("[Classpath Filter] " + testKind + " - Skipping: " + fileName + " (invalid version: " + versionGroup + ")");
excludedCount++;
continue;
}

Copilot uses AI. Check for mistakes.

// 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);
}
}
Comment on lines +311 to +343
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The loop processes all paths and performs pattern matching, tracking metadata for bundle injection. However, the foundJUnitArtifacts.add(artifactName) on line 337 is inside the version-compatible block, meaning if a JUnit artifact is excluded, it won't be tracked. Later, at line 352, paths are removed using JUNIT_VERSION_PATTERN.matcher(), which will match all JUnit artifacts regardless of whether they were tracked. This could lead to removing more artifacts than intended. Consider tracking all JUnit artifacts found, not just compatible ones, or use foundJUnitArtifacts for the removal filter.

Copilot uses AI. Check for mistakes.

// 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<String> 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<String> injectMissingJUnitBundles(TestKind testKind, Set<String> foundArtifacts) {
final List<String> 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<String> 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<File> 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();
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating a File directly from fileUrl.getPath() can cause issues on Windows when the URL path contains encoded characters (e.g., spaces as %20). Use new File(fileUrl.toURI()) instead to properly decode the URL path, or use FileLocator.toFileURL() consistently with proper decoding.

Suggested change
return new File(fileUrl.getPath()).getAbsolutePath();
return new File(fileUrl.toURI()).getAbsolutePath();

Copilot uses AI. Check for mistakes.
}
} catch (IOException e) {
JUnitPlugin.logException("Failed to get bundle path for " + bundleName, e);
}
}
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ public enum TestKind {
@SerializedName("2")
TestNG(2),

@SerializedName("3")
JUnit6(3),

@SerializedName("100")
None(100);

Expand All @@ -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:
Expand All @@ -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:
Expand Down
Loading