diff --git a/java-extension/com.microsoft.java.test.plugin.test/projects/preview-junit/.classpath b/java-extension/com.microsoft.java.test.plugin.test/projects/preview-junit/.classpath index 22230cf0d..da6b86478 100644 --- a/java-extension/com.microsoft.java.test.plugin.test/projects/preview-junit/.classpath +++ b/java-extension/com.microsoft.java.test.plugin.test/projects/preview-junit/.classpath @@ -1,5 +1,6 @@ + @@ -8,5 +9,5 @@ - + diff --git a/java-extension/com.microsoft.java.test.plugin.test/projects/preview-junit/.project b/java-extension/com.microsoft.java.test.plugin.test/projects/preview-junit/.project index 65f2d3ebc..5803584d2 100644 --- a/java-extension/com.microsoft.java.test.plugin.test/projects/preview-junit/.project +++ b/java-extension/com.microsoft.java.test.plugin.test/projects/preview-junit/.project @@ -10,8 +10,14 @@ + + org.eclipse.m2e.core.maven2Builder + + + org.eclipse.jdt.core.javanature + org.eclipse.m2e.core.maven2Nature diff --git a/java-extension/com.microsoft.java.test.plugin.test/projects/preview-junit/pom.xml b/java-extension/com.microsoft.java.test.plugin.test/projects/preview-junit/pom.xml new file mode 100644 index 000000000..6ecc548d3 --- /dev/null +++ b/java-extension/com.microsoft.java.test.plugin.test/projects/preview-junit/pom.xml @@ -0,0 +1,19 @@ + + + 4.0.0 + com.example + preview-junit + 1.0-SNAPSHOT + + 17 + 17 + + + + org.junit.jupiter + junit-jupiter + 5.14.0 + + + diff --git a/java-extension/com.microsoft.java.test.plugin.test/projects/preview-junit/src/main/java/example/AdditionalMainSource.java b/java-extension/com.microsoft.java.test.plugin.test/projects/preview-junit/src/main/java/example/AdditionalMainSource.java new file mode 100644 index 000000000..99da8fd79 --- /dev/null +++ b/java-extension/com.microsoft.java.test.plugin.test/projects/preview-junit/src/main/java/example/AdditionalMainSource.java @@ -0,0 +1,4 @@ +package example; + +public class AdditionalMainSource { +} diff --git a/java-extension/com.microsoft.java.test.plugin.test/projects/preview-junit/src/main/java/example/AdditionalMainSourceTest.java b/java-extension/com.microsoft.java.test.plugin.test/projects/preview-junit/src/main/java/example/AdditionalMainSourceTest.java new file mode 100644 index 000000000..05d2adcc4 --- /dev/null +++ b/java-extension/com.microsoft.java.test.plugin.test/projects/preview-junit/src/main/java/example/AdditionalMainSourceTest.java @@ -0,0 +1,10 @@ +package example; + +import org.junit.jupiter.api.Test; + +public class AdditionalMainSourceTest { + + @Test + public void mainSourceTest() { + } +} diff --git a/java-extension/com.microsoft.java.test.plugin.test/src/com/microsoft/java/test/plugin/util/TestSearchUtilsTest.java b/java-extension/com.microsoft.java.test.plugin.test/src/com/microsoft/java/test/plugin/util/TestSearchUtilsTest.java index f88fe4706..0e81795ed 100644 --- a/java-extension/com.microsoft.java.test.plugin.test/src/com/microsoft/java/test/plugin/util/TestSearchUtilsTest.java +++ b/java-extension/com.microsoft.java.test.plugin.test/src/com/microsoft/java/test/plugin/util/TestSearchUtilsTest.java @@ -16,7 +16,10 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import java.lang.reflect.Field; import java.util.Arrays; +import java.util.Collections; +import java.util.Collection; import java.util.List; import org.eclipse.core.resources.IProject; @@ -81,7 +84,27 @@ public void testDiscoverJUnit5TestsWithOlderPreviewSource() throws Exception { assertTrue(packages.get(0).getChildren().stream().anyMatch(item -> "MiniTest".equals(item.getLabel()))); assertTrue(packages.get(0).getChildren().stream().anyMatch(item -> "SiblingTest".equals(item.getLabel()))); assertTrue(packages.get(0).getChildren().stream().anyMatch(item -> "NestedOnlyTest".equals(item.getLabel()))); + assertTrue(packages.get(0).getChildren().stream() + .noneMatch(item -> "AdditionalMainSourceTest".equals(item.getLabel()))); + final String mainSourcePath = javaProject.getPackageFragmentRoot(project.getFolder("src/main/java")) + .getResource().getLocation().toOSString(); + final List packagesWithAdditionalSource = TestSearchUtils.findTestPackagesAndTypes( + Arrays.asList(javaProject.getHandleIdentifier(), Collections.singletonList(mainSourcePath)), + new NullProgressMonitor()); + assertTrue(packagesWithAdditionalSource.get(0).getChildren().stream() + .anyMatch(item -> "AdditionalMainSourceTest".equals(item.getLabel()))); + assertTrue(packagesWithAdditionalSource.get(0).getChildren().stream() + .anyMatch(item -> "MiniTest".equals(item.getLabel()))); + + final String additionalTestUri = javaProject.findType("example.AdditionalMainSource") + .getResource().getLocationURI().toString(); + final Object defaultNavigation = TestNavigationUtils.findTestOrTarget( + Arrays.asList(additionalTestUri, true), new NullProgressMonitor()); + assertTrue(getNavigationItems(defaultNavigation).isEmpty()); + final Object navigationWithAdditionalSource = TestNavigationUtils.findTestOrTarget(Arrays.asList( + additionalTestUri, true, Collections.singletonList(mainSourcePath)), new NullProgressMonitor()); + assertEquals(1, getNavigationItems(navigationWithAdditionalSource).size()); final List methods = TestSearchUtils.findDirectTestChildrenForClass( Arrays.asList(type.getHandleIdentifier()), new NullProgressMonitor()); assertEquals(2, methods.size()); @@ -94,4 +117,10 @@ public void testDiscoverJUnit5TestsWithOlderPreviewSource() throws Exception { assertEquals("Child", nestedChildren.get(0).getLabel()); assertEquals(JavaCore.ENABLED, javaProject.getOption(JavaCore.COMPILER_PB_ENABLE_PREVIEW_FEATURES, true)); } + + private static Collection getNavigationItems(Object navigationResult) throws Exception { + final Field itemsField = navigationResult.getClass().getField("items"); + itemsField.setAccessible(true); + return (Collection) itemsField.get(navigationResult); + } } diff --git a/java-extension/com.microsoft.java.test.plugin/plugin.xml b/java-extension/com.microsoft.java.test.plugin/plugin.xml index 246edb611..93bfb4bcb 100644 --- a/java-extension/com.microsoft.java.test.plugin/plugin.xml +++ b/java-extension/com.microsoft.java.test.plugin/plugin.xml @@ -4,6 +4,7 @@ + diff --git a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/handler/TestDelegateCommandHandler.java b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/handler/TestDelegateCommandHandler.java index 3b726cef6..8b6a1774e 100644 --- a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/handler/TestDelegateCommandHandler.java +++ b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/handler/TestDelegateCommandHandler.java @@ -31,6 +31,7 @@ public class TestDelegateCommandHandler implements IDelegateCommandHandler { private static final String GET_TEST_SOURCE_PATH = "vscode.java.test.get.testpath"; + private static final String GET_SOURCE_PATH = "vscode.java.test.get.sourcepath"; private static final String RESOLVE_JUNIT_ARGUMENT = "vscode.java.test.junit.argument"; private static final String GENERATE_TESTS = "vscode.java.test.generateTests"; private static final String FIND_JAVA_PROJECT = "vscode.java.test.findJavaProjects"; @@ -51,6 +52,8 @@ public Object executeCommand(String commandId, List arguments, IProgress switch (commandId) { case GET_TEST_SOURCE_PATH: return ProjectTestUtils.listTestSourcePaths(arguments, monitor); + case GET_SOURCE_PATH: + return ProjectTestUtils.listSourcePaths(arguments, monitor); case RESOLVE_JUNIT_ARGUMENT: return JUnitLaunchUtils.resolveLaunchArgument(arguments, monitor); case GENERATE_TESTS: diff --git a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/ProjectTestUtils.java b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/ProjectTestUtils.java index e8edcf9ed..32f25b9f4 100644 --- a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/ProjectTestUtils.java +++ b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/ProjectTestUtils.java @@ -17,6 +17,7 @@ import org.eclipse.jdt.core.IClasspathAttribute; import org.eclipse.jdt.core.IClasspathEntry; import org.eclipse.jdt.core.IJavaProject; +import org.eclipse.jdt.core.IPackageFragmentRoot; import org.eclipse.jdt.core.JavaModelException; import org.eclipse.jdt.internal.core.ClasspathEntry; import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin; @@ -25,11 +26,17 @@ import org.eclipse.jdt.ls.core.internal.managers.ProjectsManager; import java.net.URISyntaxException; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -66,6 +73,78 @@ public static List listTestSourcePaths(List arguments, I return resultList; } + @SuppressWarnings("unchecked") + public static List listSourcePaths(List arguments, IProgressMonitor monitor) + throws JavaModelException { + if (arguments == null || arguments.isEmpty()) { + return Collections.emptyList(); + } + + final ArrayList uriArray = (ArrayList) arguments.get(0); + final Map sourcePaths = new LinkedHashMap<>(); + for (final String uri : uriArray) { + if (monitor != null && monitor.isCanceled()) { + return Collections.emptyList(); + } + for (final IJavaProject project : parseProjects(uri)) { + for (final IPackageFragmentRoot root : getSourceRoots(project)) { + if (monitor != null && monitor.isCanceled()) { + return Collections.emptyList(); + } + if (root.getResource() != null && root.getResource().getLocation() != null) { + final String path = root.getResource().getLocation().toOSString(); + sourcePaths.putIfAbsent(path, new TestSourcePath(path, false)); + } + } + } + } + return new ArrayList<>(sourcePaths.values()); + } + + public static List getSourceRoots(IJavaProject project) throws JavaModelException { + if (ProjectsManager.DEFAULT_PROJECT_NAME.equals(project.getProject().getName())) { + return Collections.emptyList(); + } + + final List roots = new LinkedList<>(); + for (final IClasspathEntry entry : project.getRawClasspath()) { + if (entry.getEntryKind() == ClasspathEntry.CPE_SOURCE) { + roots.addAll(Arrays.asList(project.findPackageFragmentRoots(entry))); + } + } + return roots; + } + + public static Set getSourceRoots(IJavaProject project, Collection selectedPaths, + IProgressMonitor monitor) throws JavaModelException { + final Set requestedPaths = new HashSet<>(); + for (final String selectedPath : selectedPaths) { + if (monitor != null && monitor.isCanceled()) { + return Collections.emptySet(); + } + requestedPaths.add(normalizeSourcePath(selectedPath)); + } + if (requestedPaths.isEmpty()) { + return Collections.emptySet(); + } + + final Set matchingRoots = new LinkedHashSet<>(); + for (final IPackageFragmentRoot root : getSourceRoots(project)) { + if (monitor != null && monitor.isCanceled()) { + return Collections.emptySet(); + } + if (root.getResource() != null && root.getResource().getLocation() != null && + requestedPaths.contains(normalizeSourcePath(root.getResource().getLocation().toOSString()))) { + matchingRoots.add(root); + } + } + return matchingRoots; + } + + private static String normalizeSourcePath(String sourcePath) { + return Paths.get(sourcePath).toAbsolutePath().normalize().toString(); + } + public static List getTestEntries(IJavaProject project) throws JavaModelException { return getClasspathEntries(project, true); } diff --git a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/TestNavigationUtils.java b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/TestNavigationUtils.java index 725353990..fced00a5f 100644 --- a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/TestNavigationUtils.java +++ b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/TestNavigationUtils.java @@ -72,8 +72,16 @@ public static TestNavigationResult findTestOrTarget(List arguments, IPro } final SearchEngine searchEngine = new SearchEngine(); final boolean goToTest = (boolean) arguments.get(1); + final List additionalSourcePaths = new LinkedList<>(); + if (arguments.size() > 2 && arguments.get(2) instanceof List) { + for (final Object value : (List) arguments.get(2)) { + if (value instanceof String) { + additionalSourcePaths.add((String) value); + } + } + } final String nameToSearch = goToTest ? typeName : guessTestSubjectName(typeName); - final IJavaSearchScope scope = getSearchScope(goToTest); + final IJavaSearchScope scope = getSearchScope(goToTest, additionalSourcePaths, monitor); final IJavaProject javaProject = unit.getJavaProject(); final Set items = new HashSet<>(); searchEngine.searchAllTypeNames( @@ -96,7 +104,8 @@ public static TestNavigationResult findTestOrTarget(List arguments, IPro * @param isTest Whether containing test source or not * @throws JavaModelException */ - private static IJavaSearchScope getSearchScope(boolean isTest) throws JavaModelException { + private static IJavaSearchScope getSearchScope(boolean isTest, List additionalSourcePaths, + IProgressMonitor monitor) throws JavaModelException { final List javaElements = new LinkedList<>(); final IJavaProject[] javaProjects = ProjectUtils.getJavaProjects(); for (final IJavaProject project : javaProjects) { @@ -105,6 +114,9 @@ private static IJavaSearchScope getSearchScope(boolean isTest) throws JavaModelE for (final IClasspathEntry entry : testEntries) { javaElements.addAll(Arrays.asList(project.findPackageFragmentRoots(entry))); } + if (isTest && !additionalSourcePaths.isEmpty()) { + javaElements.addAll(ProjectTestUtils.getSourceRoots(project, additionalSourcePaths, monitor)); + } } return SearchEngine.createJavaSearchScope( diff --git a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/TestSearchUtils.java b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/TestSearchUtils.java index de9b67342..c238e1460 100644 --- a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/TestSearchUtils.java +++ b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/util/TestSearchUtils.java @@ -63,10 +63,12 @@ import org.eclipse.jdt.ls.core.internal.handlers.DocumentLifeCycleHandler; import org.eclipse.lsp4j.Location; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -144,7 +146,7 @@ public static List findJavaProjects(List arguments, IProgr * Package B * ├── ... * - * @param arguments argument list which contains the JDT handler ID of a Java project + * @param arguments argument list which contains the JDT handler ID and optional selected source-root paths * @param monitor monitor * @throws CoreException */ @@ -159,36 +161,40 @@ public static List findTestPackagesAndTypes(List arguments final IJavaProject javaProject = (IJavaProject) element; final Map testItemMapping = new HashMap<>(); final List testKinds = TestKindProvider.getTestKindsFromCache(javaProject); + final Set rootsToSearch = new LinkedHashSet<>(); + for (final IClasspathEntry entry : ProjectTestUtils.getTestEntries(javaProject)) { + rootsToSearch.addAll(Arrays.asList(javaProject.findPackageFragmentRoots(entry))); + } + rootsToSearch.addAll(getAdditionalSourceRoots(javaProject, arguments, monitor)); for (final TestKind kind : testKinds) { if (monitor != null && monitor.isCanceled()) { return Collections.emptyList(); } final TestFrameworkSearcher searcher = TestFrameworkUtils.getSearcherByTestKind(kind); final Set testTypes = new HashSet<>(); - final List testEntries = ProjectTestUtils.getTestEntries(javaProject); - for (final IClasspathEntry entry : testEntries) { - final IPackageFragmentRoot[] packageRoots = javaProject.findPackageFragmentRoots(entry); - for (final IPackageFragmentRoot root : packageRoots) { - try { - testTypes.addAll(searcher.findTestItemsInContainer(root, monitor)); - } catch (CoreException e) { - JUnitPlugin.logException("failed to search tests in: " + root.getElementName(), e); - } - if ((kind == TestKind.JUnit5 || kind == TestKind.JUnit6) && - requiresPreviewFallback(javaProject) && root.getKind() == IPackageFragmentRoot.K_SOURCE) { - for (final IJavaElement child : root.getChildren()) { - if (!(child instanceof IPackageFragment)) { - continue; + for (final IPackageFragmentRoot root : rootsToSearch) { + if (monitor != null && monitor.isCanceled()) { + return Collections.emptyList(); + } + try { + testTypes.addAll(searcher.findTestItemsInContainer(root, monitor)); + } catch (CoreException e) { + JUnitPlugin.logException("failed to search tests in: " + root.getElementName(), e); + } + if ((kind == TestKind.JUnit5 || kind == TestKind.JUnit6) && + requiresPreviewFallback(javaProject) && root.getKind() == IPackageFragmentRoot.K_SOURCE) { + for (final IJavaElement child : root.getChildren()) { + if (!(child instanceof IPackageFragment)) { + continue; + } + for (final ICompilationUnit unit : ((IPackageFragment) child).getCompilationUnits()) { + if (monitor != null && monitor.isCanceled()) { + return Collections.emptyList(); } - for (final ICompilationUnit unit : ((IPackageFragment) child).getCompilationUnits()) { - if (monitor != null && monitor.isCanceled()) { - return Collections.emptyList(); - } - for (final JavaTestItem item : findTestTypesAndMethods(unit, monitor)) { - final IJavaElement testType = JavaCore.create(item.getJdtHandler()); - if (testType instanceof IType && item.getTestKind() == kind) { - testTypes.add((IType) testType); - } + for (final JavaTestItem item : findTestTypesAndMethods(unit, monitor)) { + final IJavaElement testType = JavaCore.create(item.getJdtHandler()); + if (testType instanceof IType && item.getTestKind() == kind) { + testTypes.add((IType) testType); } } } @@ -269,6 +275,24 @@ public static List findTestPackagesAndTypes(List arguments return new LinkedList<>(mergedPackages.values()); } + private static Set getAdditionalSourceRoots(IJavaProject javaProject, + List arguments, IProgressMonitor monitor) throws JavaModelException { + if (arguments == null || arguments.size() < 2 || !(arguments.get(1) instanceof List)) { + return Collections.emptySet(); + } + + final Set requestedPaths = new HashSet<>(); + for (final Object value : (List) arguments.get(1)) { + if (monitor != null && monitor.isCanceled()) { + return Collections.emptySet(); + } + if (value instanceof String) { + requestedPaths.add((String) value); + } + } + return ProjectTestUtils.getSourceRoots(javaProject, requestedPaths, monitor); + } + /** * Find the direct declared testable class and method for a given class * @param arguments diff --git a/package-lock.json b/package-lock.json index f86f32f9e..c19970bff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.47.0", "dependencies": { "fs-extra": "^10.1.0", + "glob": "^13.0.6", "iconv-lite": "^0.7.3", "lodash": "^4.18.1", "minimatch": "^3.1.5", @@ -28,7 +29,6 @@ "@typescript-eslint/parser": "^5.60.1", "@vscode/test-electron": "^3.1.0", "eslint": "^8.43.0", - "glob": "^13.0.6", "mocha": "^11.1.0", "sinon": "^22.1.0", "ts-loader": "^9.4.2", @@ -1069,7 +1069,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -1943,7 +1942,6 @@ "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "minimatch": "^10.2.2", @@ -1970,10 +1968,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "dev": true, + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -1986,7 +1983,6 @@ "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.8" @@ -2515,7 +2511,6 @@ "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -2641,7 +2636,6 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -3044,7 +3038,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", diff --git a/package.json b/package.json index 90648aa99..a955e0090 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,9 @@ "main": "./main.js", "contributes": { "javaExtensions": [ - "./server/com.microsoft.java.test.plugin-0.43.1.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/junit-jupiter-api_5.14.4.jar", "./server/junit-jupiter-api_6.0.1.jar", "./server/junit-jupiter-engine_5.14.4.jar", @@ -63,6 +65,8 @@ "./server/junit-jupiter-migrationsupport_5.14.4.jar", "./server/junit-jupiter-params_5.14.4.jar", "./server/junit-jupiter-params_6.0.1.jar", + "./server/junit-vintage-engine_5.14.4.jar", + "./server/org.opentest4j_1.3.0.jar", "./server/junit-platform-commons_1.14.4.jar", "./server/junit-platform-commons_6.0.1.jar", "./server/junit-platform-engine_1.14.4.jar", @@ -75,16 +79,11 @@ "./server/junit-platform-suite-commons_1.14.4.jar", "./server/junit-platform-suite-engine_1.14.4.jar", "./server/junit-platform-suite-engine_6.0.1.jar", - "./server/junit-vintage-engine_5.14.4.jar", "./server/org.apiguardian.api_1.1.2.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.15.202606040825.jar", "./server/org.objectweb.asm.commons_9.10.1.jar", "./server/org.objectweb.asm.tree_9.10.1.jar", - "./server/org.objectweb.asm_9.10.1.jar", - "./server/org.opentest4j_1.3.0.jar" + "./server/org.jacoco.core_0.8.15.202606040825.jar", + "./server/com.microsoft.java.test.plugin-0.43.1.jar" ], "viewsWelcome": [ { @@ -196,6 +195,16 @@ "description": "%configuration.java.test.defaultConfig.description%", "scope": "resource" }, + "java.test.additionalTestSourcePaths": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true, + "default": [], + "description": "%configuration.java.test.additionalTestSourcePaths.description%", + "scope": "resource" + }, "java.test.config": { "type": [ "object", @@ -563,7 +572,6 @@ "@typescript-eslint/parser": "^5.60.1", "@vscode/test-electron": "^3.1.0", "eslint": "^8.43.0", - "glob": "^13.0.6", "mocha": "^11.1.0", "sinon": "^22.1.0", "ts-loader": "^9.4.2", @@ -573,6 +581,7 @@ }, "dependencies": { "fs-extra": "^10.1.0", + "glob": "^13.0.6", "iconv-lite": "^0.7.3", "lodash": "^4.18.1", "minimatch": "^3.1.5", diff --git a/package.nls.json b/package.nls.json index 312af320b..73733ce45 100644 --- a/package.nls.json +++ b/package.nls.json @@ -7,6 +7,7 @@ "contributes.commands.java.test.goToTest.title": "Go to Test", "contributes.commands.java.test.goToTestSubject.title": "Go to Test Subject", "configuration.java.test.defaultConfig.description": "Specify the name of the default test configuration.", + "configuration.java.test.additionalTestSourcePaths.description": "Paths (relative to a workspace folder or absolute) or glob patterns that select existing source roots in imported Java projects for test discovery only. They do not add source roots, dependencies, or compilation outputs, or change Maven or Gradle configuration.", "configuration.java.test.config.description": "Specify the configurations for running the tests.", "configuration.java.test.config.item.description": "Specify the configuration item for running the tests.", "configuration.java.test.config.name.description": "Specify the name of the configuration item.", diff --git a/package.nls.zh.json b/package.nls.zh.json index 443027600..a498c80bd 100644 --- a/package.nls.zh.json +++ b/package.nls.zh.json @@ -1,4 +1,5 @@ { + "configuration.java.test.additionalTestSourcePaths.description": "用于仅在已导入 Java 项目现有源根中发现测试的路径(相对于工作区文件夹或绝对路径)或 glob 模式。此设置不会添加源根、依赖或编译输出,也不会更改 Maven 或 Gradle 配置。", "description": "运行并调试 JUnit 或 TestNG 测试用例", "contributes.commands.java.test.editor.run.title": "运行测试用例", "contributes.commands.java.test.editor.debug.title": "调试测试用例", diff --git a/scripts/buildJdtlsExt.js b/scripts/buildJdtlsExt.js index c7bb407ca..3f063c991 100644 --- a/scripts/buildJdtlsExt.js +++ b/scripts/buildJdtlsExt.js @@ -9,7 +9,7 @@ const fse = require('fs-extra'); fse.removeSync('server'); const serverDir = path.resolve('java-extension'); -// Bundle prefixes to copy from the p2 repository. +// Bundle prefixes to copy from the p2 repository, in dependency load order. // Each prefix may match multiple versions (e.g., junit-jupiter-api_5.x and junit-jupiter-api_6.x) // to support both JUnit 5 and JUnit 6. const bundleList = [ @@ -30,10 +30,10 @@ const bundleList = [ 'junit-platform-suite-commons_', 'junit-platform-suite-engine_', 'org.apiguardian.api_', - 'org.jacoco.core_', - 'org.objectweb.asm_', + // JDT LS provides core ASM; load the support bundles before JaCoCo imports them. 'org.objectweb.asm.commons_', - 'org.objectweb.asm.tree_' + 'org.objectweb.asm.tree_', + 'org.jacoco.core_' ]; // Set MAVEN_OPTS to disable XML entity size limits for JDK XML parser const env = { ...process.env }; @@ -80,8 +80,11 @@ function updateVersion() { } } - // Sort for consistent ordering - newJavaExtensions.sort(); + const bundleOrder = (extensionPath) => { + const index = bundleList.findIndex(prefix => path.basename(extensionPath).startsWith(prefix)); + return index === -1 ? bundleList.length : index; + }; + newJavaExtensions.sort((a, b) => bundleOrder(a) - bundleOrder(b) || a.localeCompare(b)); packageJsonData.contributes.javaExtensions = newJavaExtensions; diff --git a/src/commands/navigation/navigationCommands.ts b/src/commands/navigation/navigationCommands.ts index c84272ad5..243a52f1f 100644 --- a/src/commands/navigation/navigationCommands.ts +++ b/src/commands/navigation/navigationCommands.ts @@ -2,8 +2,9 @@ // Licensed under the MIT license. import * as path from 'path'; -import { commands, Extension, extensions, Location, Range, Uri, window } from 'vscode'; +import { commands, Extension, extensions, Location, Range, Uri, window, workspace, WorkspaceFolder } from 'vscode'; import { JavaTestRunnerCommands, JavaTestRunnerDelegateCommands, VSCodeCommands } from '../../constants'; +import { testSourceProvider } from '../../provider/testSourceProvider'; import { SymbolTree } from '../../references-view'; import { executeJavaLanguageServerCommand } from '../../utils/commandUtils'; import { IOption } from '../askForOptionCommands'; @@ -19,7 +20,12 @@ export async function navigateToTestOrTarget(gotoTest: boolean): Promise { return; } const uri: Uri = window.activeTextEditor.document.uri; - const result: ITestNavigationResult | undefined = await searchTestOrTarget(uri.toString(), gotoTest); + const workspaceFolder: WorkspaceFolder | undefined = workspace.getWorkspaceFolder(uri); + const additionalSourcePaths: string[] = gotoTest && workspaceFolder + ? await testSourceProvider.getAdditionalTestSourcePaths(workspaceFolder) + : []; + const result: ITestNavigationResult | undefined = await searchTestOrTarget( + uri.toString(), gotoTest, additionalSourcePaths); if (!result?.items?.length) { const items: string[] = [SEARCH_FILES]; if (gotoTest) { @@ -96,9 +102,10 @@ async function fallbackForNavigation(results: ITestNavigationItem[]): Promise { +async function searchTestOrTarget( + uri: string, gotoTest: boolean, additionalSourcePaths: string[]): Promise { return await executeJavaLanguageServerCommand( - JavaTestRunnerDelegateCommands.NAVIGATE_TO_TEST_OR_TARGET, uri, gotoTest); + JavaTestRunnerDelegateCommands.NAVIGATE_TO_TEST_OR_TARGET, uri, gotoTest, additionalSourcePaths); } export interface ITestNavigationResult { diff --git a/src/constants.ts b/src/constants.ts index c0fc7c8d9..0bca932e5 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -8,6 +8,7 @@ export namespace JavaLanguageServerCommands { export namespace JavaTestRunnerDelegateCommands { export const GET_TEST_SOURCE_PATH: string = 'vscode.java.test.get.testpath'; + export const GET_SOURCE_PATH: string = 'vscode.java.test.get.sourcepath'; export const RESOLVE_JUNIT_ARGUMENT: string = 'vscode.java.test.junit.argument'; export const GENERATE_TESTS: string = 'vscode.java.test.generateTests'; export const FIND_JAVA_PROJECTS: string = 'vscode.java.test.findJavaProjects'; diff --git a/src/controller/testController.ts b/src/controller/testController.ts index de3b3c71e..029d13c93 100644 --- a/src/controller/testController.ts +++ b/src/controller/testController.ts @@ -41,9 +41,7 @@ export function createTestController(): void { testController.createRunProfile('Debug Tests', TestRunProfileKind.Debug, runHandler, true, runnableTag); testController.createRunProfile('Run Tests with Coverage', TestRunProfileKind.Coverage, runHandler, true, runnableTag); - testController.refreshHandler = () => { - refreshExplorer(); - } + testController.refreshHandler = () => refreshTestSourcePaths(); startWatchingWorkspace(); } @@ -52,6 +50,26 @@ export function creatTestProfile(name: string, kind: TestRunProfileKind): void { testController?.createRunProfile(name, kind, runHandler, false, runnableTag); } +let refreshTestSourcePathsPromise: Promise | undefined; +let refreshTestSourcePathsRequested: boolean = false; + +export function refreshTestSourcePaths(): Promise { + refreshTestSourcePathsRequested = true; + if (!refreshTestSourcePathsPromise) { + refreshTestSourcePathsPromise = (async () => { + while (refreshTestSourcePathsRequested) { + refreshTestSourcePathsRequested = false; + testSourceProvider.clear(); + await refreshExplorer(); + await startWatchingWorkspace(); + } + })().finally(() => { + refreshTestSourcePathsPromise = undefined; + }); + } + return refreshTestSourcePathsPromise; +} + export const loadChildren: (item: TestItem, token?: CancellationToken, force?: boolean) => Promise = instrumentOperation('java.test.explorer.loadChildren', async (_operationId: string, item: TestItem, token?: CancellationToken, force: boolean = false) => { if (!item) { await loadJavaProjects(); @@ -110,7 +128,29 @@ export const loadChildren: (item: TestItem, token?: CancellationToken, force?: b async function resolveTestItemChildren(item: TestItem, data: ITestItemData, resolutionVersion: number, token?: CancellationToken): Promise { if (data.testLevel === TestLevel.Project) { - const packageAndTypes: IJavaTestItem[] = await findTestPackagesAndTypes(data.jdtHandler, token); + const workspaceFolder: WorkspaceFolder | undefined = item.uri + ? workspace.getWorkspaceFolder(item.uri) + : undefined; + let additionalSourcePaths: string[] = []; + if (workspaceFolder) { + const sourcePathsPromise: Promise = + testSourceProvider.getAdditionalTestSourcePaths(workspaceFolder, token); + if (token) { + const resolvedSourcePaths: string[] | undefined = + await awaitWithCancellation(sourcePathsPromise, token); + if (token.isCancellationRequested) { + return; + } + additionalSourcePaths = resolvedSourcePaths ?? []; + } else { + additionalSourcePaths = await sourcePathsPromise; + } + } + if (token?.isCancellationRequested || resolutionVersion !== getResolutionVersion(item)) { + return; + } + const packageAndTypes: IJavaTestItem[] = await findTestPackagesAndTypes( + data.jdtHandler, token, additionalSourcePaths); if (token?.isCancellationRequested || resolutionVersion !== getResolutionVersion(item)) { return; } @@ -135,6 +175,25 @@ async function resolveTestItemChildren(item: TestItem, data: ITestItemData, reso item.canResolveChildren = false; } +function awaitWithCancellation(promise: Promise, token?: CancellationToken): Promise { + if (!token) { + return promise; + } + if (token.isCancellationRequested) { + return Promise.resolve(undefined); + } + + let cancellation: Disposable | undefined; + const cancellationPromise: Promise = new Promise((resolve: (value: undefined) => void) => { + cancellation = token.onCancellationRequested(() => resolve(undefined)); + }); + if (token.isCancellationRequested) { + cancellation?.dispose(); + return Promise.resolve(undefined); + } + return Promise.race([promise, cancellationPromise]).finally(() => cancellation?.dispose()); +} + function invalidateTestItemResolution(item: TestItem): void { const testLevel: TestLevel | undefined = dataCache.get(item)?.testLevel; if (testLevel === TestLevel.Project || testLevel === TestLevel.Class) { @@ -153,7 +212,8 @@ async function startWatchingWorkspace(): Promise { return; } - for (const disposable of watchers) { + // Remove disposed handles so repeated refreshes do not retain stale watchers. + for (const disposable of watchers.splice(0)) { disposable.dispose(); } @@ -207,6 +267,7 @@ async function startWatchingWorkspace(): Promise { }), ); } + } } diff --git a/src/controller/utils.ts b/src/controller/utils.ts index 451073364..de290de96 100644 --- a/src/controller/utils.ts +++ b/src/controller/utils.ts @@ -367,9 +367,10 @@ export async function getJavaProjects(workspaceFolder: WorkspaceFolder, token?: JavaTestRunnerDelegateCommands.FIND_JAVA_PROJECTS, workspaceFolder.uri.toString(), token) || []; } -export async function findTestPackagesAndTypes(handlerId: string, token?: CancellationToken): Promise { +export async function findTestPackagesAndTypes(handlerId: string, token?: CancellationToken, + additionalSourcePaths: string[] = []): Promise { return await executeJavaLanguageServerCommand( - JavaTestRunnerDelegateCommands.FIND_TEST_PACKAGES_AND_TYPES, handlerId, token) || []; + JavaTestRunnerDelegateCommands.FIND_TEST_PACKAGES_AND_TYPES, handlerId, additionalSourcePaths, token) || []; } export async function findDirectTestChildrenForClass(handlerId: string, token?: CancellationToken): Promise { diff --git a/src/extension.ts b/src/extension.ts index 18a786c79..a95330d35 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license. import * as path from 'path'; -import { commands, DebugConfiguration, Event, Extension, ExtensionContext, extensions, TestItem, TestRunProfileKind, TextDocument, TextDocumentChangeEvent, TextEditor, Uri, window, workspace, WorkspaceFoldersChangeEvent } from 'vscode'; +import { commands, ConfigurationChangeEvent, DebugConfiguration, Event, Extension, ExtensionContext, extensions, TestItem, TestRunProfileKind, TextDocument, TextDocumentChangeEvent, TextEditor, Uri, window, workspace } from 'vscode'; import { dispose as disposeTelemetryWrapper, initializeFromJsonFile, instrumentOperation, instrumentOperationAsVsCodeCommand } from 'vscode-extension-telemetry-wrapper'; import { navigateToTestOrTarget } from './commands/navigation/navigationCommands'; import { generateTests } from './commands/generationCommands'; @@ -10,7 +10,7 @@ import { runTestsFromJavaProjectExplorer } from './commands/projectExplorerComma import { refreshExplorer, refreshProject, runTestsFromTestExplorer } from './commands/testExplorerCommands'; import { openStackTrace } from './commands/testReportCommands'; import { Context, ExtensionName, JavaTestRunnerCommands, VSCodeCommands } from './constants'; -import { createTestController, testController, watchers } from './controller/testController'; +import { createTestController, refreshTestSourcePaths, testController, watchers } from './controller/testController'; import { updateItemForDocument, updateItemForDocumentWithDebounce } from './controller/utils'; import { IProgressProvider } from './debugger.api'; import { initExpService } from './experimentationService'; @@ -45,6 +45,7 @@ export async function activate(context: ExtensionContext): Promise { export async function deactivate(): Promise { disposeCodeActionProvider(); await disposeTelemetryWrapper(); + testSourceProvider.dispose(); testController?.dispose(); for (const disposable of watchers) { disposable.dispose(); @@ -131,10 +132,13 @@ function registerComponents(context: ExtensionContext): void { await updateItemForDocumentWithDebounce(e.document.uri); } }), - workspace.onDidChangeWorkspaceFolders(async (e: WorkspaceFoldersChangeEvent) => { - for (const deletedFolder of e.removed) { - testSourceProvider.delete(deletedFolder.uri); + workspace.onDidChangeConfiguration(async (event: ConfigurationChangeEvent) => { + if (event.affectsConfiguration('java.test.additionalTestSourcePaths')) { + await refreshTestSourcePaths(); } + }), + workspace.onDidChangeWorkspaceFolders(async () => { + testSourceProvider.clear(); // workaround to wait for Java Language Server to accept the workspace folder change event, // otherwise we cannot find the projects in the new workspace folder. // TODO: this event should be notified by onDidProjectsImport, we need to fix upstream diff --git a/src/provider/testSourceProvider.ts b/src/provider/testSourceProvider.ts index d9fab1e46..8b9be4446 100644 --- a/src/provider/testSourceProvider.ts +++ b/src/provider/testSourceProvider.ts @@ -2,12 +2,15 @@ // Licensed under the MIT license. import * as path from 'path'; -import { RelativePattern, Uri, workspace, WorkspaceFolder } from 'vscode'; +import * as minimatch from 'minimatch'; +import { hasMagic } from 'glob'; +import { CancellationToken, OutputChannel, RelativePattern, Uri, window, workspace, WorkspaceFolder } from 'vscode'; import { JavaTestRunnerDelegateCommands } from '../constants'; import { executeJavaLanguageServerCommand } from '../utils/commandUtils'; class TestSourcePathProvider { - private testSourceMapping: Map = new Map(); + private testSourceMapping: Map> = new Map(); + private additionalTestSourceMapping: Map> = new Map(); public async getTestSourcePattern(workspaceFolder: WorkspaceFolder, containsGeneral: boolean = true): Promise { const patterns: RelativePattern[] = []; @@ -31,6 +34,39 @@ class TestSourcePathProvider { .map((s: ITestSourcePath) => s.testSourcePath); } + public getAdditionalTestSourcePaths(workspaceFolder: WorkspaceFolder, token?: CancellationToken): Promise { + const configuredPaths: string[] = workspace.getConfiguration('java.test', workspaceFolder.uri) + .get('additionalTestSourcePaths', []); + if (configuredPaths.length === 0) { + return Promise.resolve([]); + } + if (token) { + if (token.isCancellationRequested) { + return Promise.resolve([]); + } + return getSourcePaths([workspaceFolder.uri.toString()], token).then((sourcePaths: ITestSourcePath[]) => + token.isCancellationRequested + ? [] + : resolveAdditionalTestSourcePaths(workspaceFolder.uri.fsPath, configuredPaths, + sourcePaths.map((sourcePath: ITestSourcePath) => sourcePath.testSourcePath))); + } + + const workspaceKey: string = workspaceFolder.uri.toString(); + let resolvedPaths: Promise | undefined = this.additionalTestSourceMapping.get(workspaceKey); + if (!resolvedPaths) { + resolvedPaths = getSourcePaths([workspaceFolder.uri.toString()]).then((sourcePaths: ITestSourcePath[]) => + resolveAdditionalTestSourcePaths(workspaceFolder.uri.fsPath, configuredPaths, + sourcePaths.map((sourcePath: ITestSourcePath) => sourcePath.testSourcePath))); + this.additionalTestSourceMapping.set(workspaceKey, resolvedPaths); + resolvedPaths.catch(() => { + if (this.additionalTestSourceMapping.get(workspaceKey) === resolvedPaths) { + this.additionalTestSourceMapping.delete(workspaceKey); + } + }); + } + return resolvedPaths; + } + public async isOnTestSourcePath(uri: Uri): Promise { const workspaceFolder: WorkspaceFolder | undefined = workspace.getWorkspaceFolder(uri); if (!workspaceFolder) { @@ -49,20 +85,131 @@ class TestSourcePathProvider { public clear(): void { this.testSourceMapping.clear(); + this.additionalTestSourceMapping.clear(); } - public delete(workspaceUri: Uri): boolean { - return this.testSourceMapping.delete(workspaceUri); + public dispose(): void { + additionalTestSourceOutputChannel?.dispose(); + additionalTestSourceOutputChannel = undefined; } private async getTestPaths(workspaceFolder: WorkspaceFolder): Promise { - let testPaths: ITestSourcePath[] | undefined = this.testSourceMapping.get(workspaceFolder.uri); + const workspaceKey: string = workspaceFolder.uri.toString(); + let testPaths: Promise | undefined = this.testSourceMapping.get(workspaceKey); if (!testPaths) { - testPaths = await getTestSourcePaths([workspaceFolder.uri.toString()]); - this.testSourceMapping.set(workspaceFolder.uri, testPaths); + testPaths = getTestSourcePaths([workspaceFolder.uri.toString()]); + this.testSourceMapping.set(workspaceKey, testPaths); + testPaths.catch(() => { + if (this.testSourceMapping.get(workspaceKey) === testPaths) { + this.testSourceMapping.delete(workspaceKey); + } + }); + } + + return mergeTestSourcePaths(await testPaths, await this.getAdditionalTestSourcePaths(workspaceFolder)); + } +} + +let additionalTestSourceOutputChannel: OutputChannel | undefined; +const LARGE_ADDITIONAL_TEST_SOURCE_COUNT: number = 100; + +export function mergeTestSourcePaths(testPaths: ITestSourcePath[], additionalPaths: string[]): ITestSourcePath[] { + const mergedPaths: ITestSourcePath[] = []; + const pathIndexes: Map = new Map(); + for (const testPath of testPaths) { + const key: string = getPathKey(testPath.testSourcePath); + const existingIndex: number | undefined = pathIndexes.get(key); + if (existingIndex !== undefined) { + mergedPaths[existingIndex].isStrict ||= testPath.isStrict; + continue; } - return testPaths; + + pathIndexes.set(key, mergedPaths.length); + mergedPaths.push({ ...testPath }); } + + for (const additionalPath of additionalPaths) { + const key: string = getPathKey(additionalPath); + const existingIndex: number | undefined = pathIndexes.get(key); + if (existingIndex !== undefined) { + mergedPaths[existingIndex].isStrict = true; + continue; + } + + pathIndexes.set(key, mergedPaths.length); + mergedPaths.push({ testSourcePath: additionalPath, isStrict: true }); + } + return mergedPaths; +} + +export async function resolveAdditionalTestSourcePaths( + workspacePath: string, configuredPaths: string[], importedSourcePaths: string[]): Promise { + const paths: string[] = []; + const pathKeys: Set = new Set(); + const sourcePathByKey: Map = new Map(); + for (const sourcePath of importedSourcePaths) { + sourcePathByKey.set(getPathKey(sourcePath), sourcePath); + } + + for (const configuredPath of configuredPaths) { + if (!configuredPath.trim()) { + continue; + } + + const normalizedPattern: string = configuredPath.trim().replace(/\\/g, '/'); + const resolvedPattern: string = path.resolve(workspacePath, normalizedPattern); + if (!hasMagic(normalizedPattern)) { + const sourcePath: string | undefined = sourcePathByKey.get(getPathKey(resolvedPattern)); + if (sourcePath) { + addUniquePath(paths, pathKeys, sourcePath); + } + continue; + } + + try { + const matcher: minimatch.Minimatch = new minimatch.Minimatch( + path.isAbsolute(normalizedPattern) + ? normalizedPattern + : normalizedPattern.replace(/^\.\//, ''), + { nocase: process.platform === 'win32' }); + for (const sourcePath of sourcePathByKey.values()) { + const matchPath: string = path.isAbsolute(normalizedPattern) + ? sourcePath.replace(/\\/g, '/') + : path.relative(workspacePath, sourcePath).replace(/\\/g, '/'); + if (matchPath !== '..' && !matchPath.startsWith('../') && !path.isAbsolute(matchPath) && + matcher.match(matchPath)) { + addUniquePath(paths, pathKeys, sourcePath); + } + } + } catch (error) { + const message: string = error instanceof Error ? error.message : String(error); + logAdditionalTestSourceMessage(`Failed to match additional test source pattern "${configuredPath}": ${message}`); + } + } + + if (paths.length > LARGE_ADDITIONAL_TEST_SOURCE_COUNT) { + logAdditionalTestSourceMessage( + `Additional test source patterns matched ${paths.length} imported source roots. Broad patterns may slow discovery.`); + } + return paths; +} + +function logAdditionalTestSourceMessage(message: string): void { + additionalTestSourceOutputChannel ??= window.createOutputChannel('Test Runner for Java'); + additionalTestSourceOutputChannel.appendLine(message); +} + +function addUniquePath(paths: string[], pathKeys: Set, sourcePath: string): void { + const key: string = getPathKey(sourcePath); + if (!pathKeys.has(key)) { + paths.push(sourcePath); + pathKeys.add(key); + } +} + +function getPathKey(sourcePath: string): string { + const normalizedPath: string = path.normalize(sourcePath); + return process.platform === 'win32' ? normalizedPath.toLowerCase() : normalizedPath; } async function getTestSourcePaths(uri: string[]): Promise { @@ -70,7 +217,16 @@ async function getTestSourcePaths(uri: string[]): Promise { JavaTestRunnerDelegateCommands.GET_TEST_SOURCE_PATH, uri) || []; } -interface ITestSourcePath { +async function getSourcePaths(uri: string[], token?: CancellationToken): Promise { + if (token) { + return await executeJavaLanguageServerCommand( + JavaTestRunnerDelegateCommands.GET_SOURCE_PATH, uri, token) || []; + } + return await executeJavaLanguageServerCommand( + JavaTestRunnerDelegateCommands.GET_SOURCE_PATH, uri) || []; +} + +export interface ITestSourcePath { testSourcePath: string; /** * All the source paths from eclipse and invisible project will be treated as test source diff --git a/test/suite/navigation.test.ts b/test/suite/navigation.test.ts index 1edd91dd7..9278a77fa 100644 --- a/test/suite/navigation.test.ts +++ b/test/suite/navigation.test.ts @@ -5,10 +5,13 @@ import * as assert from 'assert'; import * as path from 'path'; -import { Location, Uri, window } from 'vscode'; -import { ITestNavigationResult } from '../../src/commands/navigation/navigationCommands'; +import * as sinon from 'sinon'; +import { Location, Range, Uri, window } from 'vscode'; +import { ITestNavigationResult, navigateToTestOrTarget } from '../../src/commands/navigation/navigationCommands'; import { JavaTestRunnerDelegateCommands } from '../../src/constants'; +import { testSourceProvider } from '../../src/provider/testSourceProvider'; import { findTestLocation } from '../../src/runners/utils'; +import * as commandUtils from '../../src/utils/commandUtils'; import { executeJavaLanguageServerCommand } from '../../src/utils/commandUtils'; import { setupTestEnv } from "./utils"; @@ -43,6 +46,32 @@ suite('Test Navigation Tests', () => { assert.strictEqual(searchResult?.items[0].fullyQualifiedName, 'junit.App'); }); + test('passes selected source roots to Go to Test navigation', async () => { + const filePath: string = path.join(PROJECT_PATH, 'src', 'main', 'java', 'junit', 'App.java'); + await window.showTextDocument(Uri.file(filePath)); + const uri: Uri = window.activeTextEditor!.document.uri; + const additionalSourcePaths: string[] = [path.join(PROJECT_PATH, 'src', 'main', 'java')]; + const result: ITestNavigationResult = { + items: [{ + simpleName: 'AppTest', + fullyQualifiedName: 'junit5.AppTest', + uri: uri.toString(), + relevance: 0, + outOfBelongingProject: false, + }], + location: new Location(uri, new Range(0, 0, 0, 0)), + }; + sinon.stub(testSourceProvider, 'getAdditionalTestSourcePaths').resolves(additionalSourcePaths); + const commandStub = sinon.stub(commandUtils, 'executeJavaLanguageServerCommand').resolves(result); + try { + await navigateToTestOrTarget(true); + sinon.assert.calledOnceWithExactly(commandStub, + JavaTestRunnerDelegateCommands.NAVIGATE_TO_TEST_OR_TARGET, uri.toString(), true, additionalSourcePaths); + } finally { + sinon.restore(); + } + }); + test('test find inherited test method location', async () => { const location: Location | undefined = await findTestLocation("junit@junit4.ExtendedTest#test"); assert.ok(location?.uri.fsPath.endsWith("BaseTest.java")); diff --git a/test/suite/testController.loadChildren.test.ts b/test/suite/testController.loadChildren.test.ts index cdf54e51a..828826347 100644 --- a/test/suite/testController.loadChildren.test.ts +++ b/test/suite/testController.loadChildren.test.ts @@ -3,12 +3,14 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; -import { CancellationTokenSource, TestController, TestItem, tests, Uri } from 'vscode'; -import { loadChildren } from '../../src/controller/testController'; +import { CancellationTokenSource, TestController, TestItem, tests, Uri, workspace, WorkspaceFolder } from 'vscode'; +import { loadChildren, refreshTestSourcePaths } from '../../src/controller/testController'; import { dataCache, invalidateResolutionVersion } from '../../src/controller/testItemDataCache'; import * as controllerUtils from '../../src/controller/utils'; +import * as testExplorerCommands from '../../src/commands/testExplorerCommands'; import { TestKind, TestLevel } from '../../src/java-test-runner.api'; import { IJavaTestItem } from '../../src/types'; +import { testSourceProvider } from '../../src/provider/testSourceProvider'; import { setupTestEnv } from './utils'; function createTestItem(testController: TestController, id: string, testLevel: TestLevel, @@ -43,6 +45,85 @@ suite('testController - loadChildren', () => { testController.dispose(); }); + test('refreshes test source paths after clearing cached discovery paths', async () => { + const clearPathsStub = sinon.stub(testSourceProvider, 'clear'); + const refreshExplorerStub = sinon.stub(testExplorerCommands, 'refreshExplorer').resolves(); + const watcherPatternsStub = sinon.stub(testSourceProvider, 'getTestSourcePattern').resolves([]); + + await refreshTestSourcePaths(); + + sinon.assert.callOrder(clearPathsStub, refreshExplorerStub, watcherPatternsStub); + }); + + test('serializes overlapping source-path refresh requests', async () => { + let releaseFirstRefresh!: () => void; + let signalFirstRefreshStarted!: () => void; + const firstRefreshStarted: Promise = new Promise((resolve) => { + signalFirstRefreshStarted = resolve; + }); + const firstRefreshGate: Promise = new Promise((resolve) => { + releaseFirstRefresh = resolve; + }); + const clearPathsStub = sinon.stub(testSourceProvider, 'clear'); + const refreshExplorerStub = sinon.stub(testExplorerCommands, 'refreshExplorer').callsFake(async () => { + if (refreshExplorerStub.callCount === 1) { + signalFirstRefreshStarted(); + await firstRefreshGate; + } + }); + const watcherPatternsStub = sinon.stub(testSourceProvider, 'getTestSourcePattern').resolves([]); + + const firstRefresh: Promise = refreshTestSourcePaths(); + await firstRefreshStarted; + const overlappingRefresh: Promise = refreshTestSourcePaths(); + releaseFirstRefresh(); + await Promise.all([firstRefresh, overlappingRefresh]); + + assert.strictEqual(clearPathsStub.callCount, 2); + assert.strictEqual(refreshExplorerStub.callCount, 2); + assert.strictEqual(watcherPatternsStub.callCount, 2); + }); + + test('passes configured imported source roots to backend project discovery', async () => { + const projectUri: Uri = Uri.file('/workspace/module'); + const workspaceFolder: WorkspaceFolder = { + uri: Uri.file('/workspace'), + name: 'workspace', + index: 0, + }; + const additionalSourcePath: string = '/workspace/module/src/main/java'; + const project: TestItem = createTestItem(testController, 'project', TestLevel.Project, undefined, projectUri); + sinon.stub(workspace, 'getWorkspaceFolder').returns(workspaceFolder); + sinon.stub(testSourceProvider, 'getAdditionalTestSourcePaths').resolves([additionalSourcePath]); + const findPackagesStub = sinon.stub(controllerUtils, 'findTestPackagesAndTypes').resolves([]); + + await loadChildren(project); + + sinon.assert.calledOnceWithExactly(findPackagesStub, 'project-handler', undefined, [additionalSourcePath]); + }); + + test('removes additional-root tests when a refresh no longer selects their source root', async () => { + const projectUri: Uri = Uri.file('/workspace/module'); + const workspaceFolder: WorkspaceFolder = { + uri: Uri.file('/workspace'), + name: 'workspace', + index: 0, + }; + const project: TestItem = createTestItem(testController, 'project', TestLevel.Project, undefined, projectUri); + const testPackage: TestItem = createTestItem(testController, 'project@example', TestLevel.Package, project); + createTestItem(testController, 'project@example.AdditionalTest', TestLevel.Class, testPackage, + Uri.file('/workspace/module/src/main/java/example/AdditionalTest.java')); + sinon.stub(workspace, 'getWorkspaceFolder').returns(workspaceFolder); + sinon.stub(testSourceProvider, 'getAdditionalTestSourcePaths').resolves([]); + const findPackagesStub = sinon.stub(controllerUtils, 'findTestPackagesAndTypes'); + findPackagesStub.resolves([]); + + await loadChildren(project, undefined, true); + + assert.strictEqual(project.children.size, 0); + sinon.assert.calledOnceWithExactly(findPackagesStub, 'project-handler', undefined, []); + }); + test('should reuse resolved project children until a forced refresh', async () => { const project: TestItem = createTestItem(testController, 'project', TestLevel.Project); const findTestsStub = sinon.stub(controllerUtils, 'findTestPackagesAndTypes').resolves([]); @@ -342,4 +423,41 @@ suite('testController - loadChildren', () => { assert.strictEqual(project.canResolveChildren, true); source.dispose(); }); + + test('does not start backend discovery when cancellation occurs during source-root resolution', async () => { + const project: TestItem = createTestItem( + testController, 'project', TestLevel.Project, undefined, Uri.file('/workspace/module')); + const workspaceFolder: WorkspaceFolder = { + uri: Uri.file('/workspace'), + name: 'workspace', + index: 0, + }; + let releaseSourcePaths!: (sourcePaths: string[]) => void; + let signalSourcePathsRequested!: () => void; + const sourcePathsRequested: Promise = new Promise((resolve) => { + signalSourcePathsRequested = resolve; + }); + const sourcePaths: Promise = new Promise((resolve) => { + releaseSourcePaths = resolve; + }); + const cancellation: CancellationTokenSource = new CancellationTokenSource(); + sinon.stub(workspace, 'getWorkspaceFolder').returns(workspaceFolder); + sinon.stub(testSourceProvider, 'getAdditionalTestSourcePaths').callsFake((folder, token) => { + assert.strictEqual(folder, workspaceFolder); + assert.strictEqual(token, cancellation.token); + signalSourcePathsRequested(); + return sourcePaths; + }); + const findPackagesStub = sinon.stub(controllerUtils, 'findTestPackagesAndTypes').resolves([]); + + const resolution: Promise = loadChildren(project, cancellation.token); + await sourcePathsRequested; + cancellation.cancel(); + await resolution; + releaseSourcePaths(['/workspace/module/src/main/java']); + + assert.ok(findPackagesStub.notCalled); + assert.strictEqual(project.canResolveChildren, true); + cancellation.dispose(); + }); }); diff --git a/test/suite/testSourceProvider.test.ts b/test/suite/testSourceProvider.test.ts new file mode 100644 index 000000000..b2f228bc3 --- /dev/null +++ b/test/suite/testSourceProvider.test.ts @@ -0,0 +1,345 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as assert from 'assert'; +import * as fse from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { CancellationTokenSource, RelativePattern, Uri, WorkspaceConfiguration, WorkspaceFolder, workspace } from 'vscode'; +import * as commandUtils from '../../src/utils/commandUtils'; +import { JavaTestRunnerDelegateCommands } from '../../src/constants'; +import { ITestSourcePath, mergeTestSourcePaths, resolveAdditionalTestSourcePaths, testSourceProvider } from '../../src/provider/testSourceProvider'; + +suite('testSourceProvider', () => { + test('returns no extra paths by default', async () => { + assert.deepStrictEqual(await resolveAdditionalTestSourcePaths(path.resolve('workspace'), [], []), []); + }); + + test('resolves multiple relative paths against the workspace folder', async () => { + const workspacePath: string = fse.mkdtempSync(path.join(os.tmpdir(), 'java-test-source-paths-')); + const relativePaths: string[] = [path.join('src', 'main', 'java'), path.join('src', 'integrationTest', 'java')]; + relativePaths.forEach((relativePath: string) => fse.ensureDirSync(path.join(workspacePath, relativePath))); + try { + const sourcePaths: string[] = await resolveAdditionalTestSourcePaths( + workspacePath, relativePaths, relativePaths.map((relativePath: string) => path.resolve(workspacePath, relativePath))); + + assert.deepStrictEqual(sourcePaths, relativePaths.map((relativePath: string) => path.resolve(workspacePath, relativePath))); + } finally { + fse.removeSync(workspacePath); + } + }); + + test('deduplicates equivalent Windows path spellings', async () => { + const workspacePath: string = fse.mkdtempSync(path.join(os.tmpdir(), 'java-test-source-paths-')); + const relativePath: string = path.join('src', 'main', 'java'); + fse.ensureDirSync(path.resolve(workspacePath, relativePath)); + try { + const sourcePaths: string[] = await resolveAdditionalTestSourcePaths(workspacePath, [ + relativePath, + relativePath.replace(/\\/g, '/'), + path.resolve(workspacePath, relativePath), + ], [path.resolve(workspacePath, relativePath)]); + + assert.deepStrictEqual(sourcePaths, [path.resolve(workspacePath, relativePath)]); + } finally { + fse.removeSync(workspacePath); + } + }); + + test('merges an additional path already discovered by Java without duplicating it', () => { + const sourcePath: string = path.resolve('workspace', 'src', 'main', 'java'); + const paths: ITestSourcePath[] = mergeTestSourcePaths( + [{ testSourcePath: sourcePath, isStrict: false }], [sourcePath]); + + assert.deepStrictEqual(paths, [{ testSourcePath: sourcePath, isStrict: true }]); + }); + + test('expands source-root patterns and deduplicates overlapping matches', async () => { + const workspacePath: string = fse.mkdtempSync(path.join(os.tmpdir(), 'java-test-source-paths-')); + const sourceRoots: string[] = [ + path.join(workspacePath, 'modules', 'app', 'src', 'main', 'java'), + path.join(workspacePath, 'modules', 'cli', 'src', 'main', 'java'), + ]; + sourceRoots.forEach((sourceRoot: string) => fse.ensureDirSync(sourceRoot)); + + try { + const paths: string[] = await resolveAdditionalTestSourcePaths(workspacePath, [ + 'modules/*/src/main/java', + 'modules/app/src/main/java', + ], sourceRoots); + + assert.deepStrictEqual(paths.sort(), sourceRoots.sort()); + } finally { + fse.removeSync(workspacePath); + } + }); + + test('includes additional source roots in file watcher patterns', async () => { + const workspacePath: string = fse.mkdtempSync(path.join(os.tmpdir(), 'java-test-source-paths-')); + const sourceRoots: string[] = [ + path.join(workspacePath, 'modules', 'app', 'src', 'main', 'java'), + path.join(workspacePath, 'modules', 'cli', 'src', 'main', 'java'), + ]; + sourceRoots.forEach((sourceRoot: string) => fse.ensureDirSync(sourceRoot)); + const workspaceFolder: WorkspaceFolder = { + uri: Uri.file(workspacePath), + name: 'additional-source-paths-test', + index: 0, + }; + const configuration: WorkspaceConfiguration = { + get: () => ['modules/*/src/main/java'], + } as unknown as WorkspaceConfiguration; + + testSourceProvider.clear(); + sinon.stub(workspace, 'getConfiguration').returns(configuration); + sinon.stub(commandUtils, 'executeJavaLanguageServerCommand').resolves([]); + sinon.stub(testSourceProvider, 'getAdditionalTestSourcePaths').resolves(sourceRoots); + try { + const patterns: RelativePattern[] = await testSourceProvider.getTestSourcePattern(workspaceFolder); + + const normalizePath = (sourcePath: string): string => process.platform === 'win32' + ? path.normalize(sourcePath).toLowerCase() + : path.normalize(sourcePath); + assert.deepStrictEqual( + patterns.map((pattern: RelativePattern) => normalizePath(pattern.baseUri.fsPath)).sort(), + sourceRoots.map(normalizePath).sort()); + } finally { + testSourceProvider.clear(); + sinon.restore(); + fse.removeSync(workspacePath); + } + }); + + test('resolves configured patterns only against imported Java source roots', async () => { + const workspacePath: string = fse.mkdtempSync(path.join(os.tmpdir(), 'java-test-source-paths-')); + const importedRoot: string = path.join(workspacePath, 'modules', 'app', 'src', 'main', 'java'); + const unimportedPath: string = path.join(workspacePath, 'modules', 'scratch', 'src', 'main', 'java'); + const workspaceFolder: WorkspaceFolder = { + uri: Uri.file(workspacePath), + name: 'additional-source-paths-test', + index: 0, + }; + const configuration: WorkspaceConfiguration = { + get: () => ['modules/*/src/main/java'], + } as unknown as WorkspaceConfiguration; + + testSourceProvider.clear(); + sinon.stub(workspace, 'getConfiguration').returns(configuration); + const commandStub = sinon.stub(commandUtils, 'executeJavaLanguageServerCommand') + .resolves([{ testSourcePath: importedRoot, isStrict: false }]); + try { + assert.deepStrictEqual(await testSourceProvider.getAdditionalTestSourcePaths(workspaceFolder), [importedRoot]); + sinon.assert.calledOnceWithExactly(commandStub, + JavaTestRunnerDelegateCommands.GET_SOURCE_PATH, [workspaceFolder.uri.toString()]); + assert.ok(![...await testSourceProvider.getAdditionalTestSourcePaths(workspaceFolder)].includes(unimportedPath)); + } finally { + testSourceProvider.clear(); + sinon.restore(); + fse.removeSync(workspacePath); + } + }); + + test('retries additional source-root lookup after a command failure', async () => { + const workspacePath: string = fse.mkdtempSync(path.join(os.tmpdir(), 'java-test-source-paths-')); + const sourceRoot: string = path.join(workspacePath, 'src', 'main', 'java'); + const workspaceFolder: WorkspaceFolder = { + uri: Uri.file(workspacePath), + name: 'additional-source-paths-test', + index: 0, + }; + const configuration: WorkspaceConfiguration = { + get: () => ['src/main/java'], + } as unknown as WorkspaceConfiguration; + + testSourceProvider.clear(); + sinon.stub(workspace, 'getConfiguration').returns(configuration); + const commandStub = sinon.stub(commandUtils, 'executeJavaLanguageServerCommand'); + commandStub.onFirstCall().rejects(new Error('Temporary Java Language Server failure')); + commandStub.onSecondCall().resolves([{ testSourcePath: sourceRoot, isStrict: false }]); + try { + await assert.rejects(testSourceProvider.getAdditionalTestSourcePaths(workspaceFolder), + /Temporary Java Language Server failure/); + assert.deepStrictEqual(await testSourceProvider.getAdditionalTestSourcePaths(workspaceFolder), [sourceRoot]); + assert.strictEqual(commandStub.callCount, 2); + } finally { + testSourceProvider.clear(); + sinon.restore(); + fse.removeSync(workspacePath); + } + }); + + test('passes cancellation tokens to uncached Java source-root lookups', async () => { + const workspacePath: string = fse.mkdtempSync(path.join(os.tmpdir(), 'java-test-source-paths-')); + const sourceRoot: string = path.join(workspacePath, 'src', 'main', 'java'); + const workspaceFolder: WorkspaceFolder = { + uri: Uri.file(workspacePath), + name: 'additional-source-paths-test', + index: 0, + }; + const configuration: WorkspaceConfiguration = { + get: () => ['src/main/java'], + } as unknown as WorkspaceConfiguration; + const cancellation: CancellationTokenSource = new CancellationTokenSource(); + + testSourceProvider.clear(); + sinon.stub(workspace, 'getConfiguration').returns(configuration); + const commandStub = sinon.stub(commandUtils, 'executeJavaLanguageServerCommand') + .resolves([{ testSourcePath: sourceRoot, isStrict: false }]); + try { + assert.deepStrictEqual( + await testSourceProvider.getAdditionalTestSourcePaths(workspaceFolder, cancellation.token), [sourceRoot]); + sinon.assert.calledOnceWithExactly(commandStub, + JavaTestRunnerDelegateCommands.GET_SOURCE_PATH, [workspaceFolder.uri.toString()], cancellation.token); + } finally { + cancellation.dispose(); + testSourceProvider.clear(); + sinon.restore(); + fse.removeSync(workspacePath); + } + }); + + test('deduplicates concurrent test source path lookups', async () => { + const workspacePath: string = fse.mkdtempSync(path.join(os.tmpdir(), 'java-test-source-paths-')); + const sourceRoot: string = path.join(workspacePath, 'src', 'test', 'java'); + const workspaceFolder: WorkspaceFolder = { + uri: Uri.file(workspacePath), + name: 'test-source-paths-test', + index: 0, + }; + + testSourceProvider.clear(); + sinon.stub(testSourceProvider, 'getAdditionalTestSourcePaths').resolves([]); + const commandStub = sinon.stub(commandUtils, 'executeJavaLanguageServerCommand') + .resolves([{ testSourcePath: sourceRoot, isStrict: true }]); + try { + const results: string[][] = await Promise.all([ + testSourceProvider.getTestSourcePath(workspaceFolder), + testSourceProvider.getTestSourcePath(workspaceFolder), + ]); + + assert.deepStrictEqual(results, [[sourceRoot], [sourceRoot]]); + sinon.assert.calledOnceWithExactly(commandStub, + JavaTestRunnerDelegateCommands.GET_TEST_SOURCE_PATH, [workspaceFolder.uri.toString()]); + } finally { + testSourceProvider.clear(); + sinon.restore(); + fse.removeSync(workspacePath); + } + }); + + test('recomputes additional source roots after cached paths are cleared', async () => { + const workspacePath: string = fse.mkdtempSync(path.join(os.tmpdir(), 'java-test-source-paths-')); + const sourceRoots: string[] = [ + path.join(workspacePath, 'modules', 'app', 'src', 'main', 'java'), + path.join(workspacePath, 'modules', 'cli', 'src', 'main', 'java'), + ]; + let configuredPaths: string[] = ['modules/app/src/main/java']; + const workspaceFolder: WorkspaceFolder = { + uri: Uri.file(workspacePath), + name: 'additional-source-paths-test', + index: 0, + }; + const configuration: WorkspaceConfiguration = { + get: () => configuredPaths, + } as unknown as WorkspaceConfiguration; + + testSourceProvider.clear(); + sinon.stub(workspace, 'getConfiguration').returns(configuration); + const commandStub = sinon.stub(commandUtils, 'executeJavaLanguageServerCommand').resolves( + sourceRoots.map((testSourcePath: string) => ({ testSourcePath, isStrict: false }))); + try { + assert.deepStrictEqual(await testSourceProvider.getAdditionalTestSourcePaths(workspaceFolder), [sourceRoots[0]]); + configuredPaths = ['modules/cli/src/main/java']; + assert.deepStrictEqual(await testSourceProvider.getAdditionalTestSourcePaths(workspaceFolder), [sourceRoots[0]]); + assert.strictEqual(commandStub.callCount, 1); + + testSourceProvider.clear(); + assert.deepStrictEqual(await testSourceProvider.getAdditionalTestSourcePaths(workspaceFolder), [sourceRoots[1]]); + assert.strictEqual(commandStub.callCount, 2); + } finally { + testSourceProvider.clear(); + sinon.restore(); + fse.removeSync(workspacePath); + } + }); + + test('keeps additional source-root resolution separate for workspace folders', async () => { + const workspacePath: string = fse.mkdtempSync(path.join(os.tmpdir(), 'java-test-source-paths-')); + const workspaceFolderA: WorkspaceFolder = { + uri: Uri.file(path.join(workspacePath, 'module-a')), + name: 'module-a', + index: 0, + }; + const workspaceFolderB: WorkspaceFolder = { + uri: Uri.file(path.join(workspacePath, 'module-b')), + name: 'module-b', + index: 1, + }; + const sourceRootA: string = path.join(workspaceFolderA.uri.fsPath, 'src', 'main', 'java'); + const sourceRootB: string = path.join(workspaceFolderB.uri.fsPath, 'src', 'main', 'java'); + const configuration: WorkspaceConfiguration = { + get: () => ['src/main/java'], + } as unknown as WorkspaceConfiguration; + + testSourceProvider.clear(); + sinon.stub(workspace, 'getConfiguration').returns(configuration); + const commandStub = sinon.stub(commandUtils, 'executeJavaLanguageServerCommand'); + commandStub.onFirstCall().resolves([{ testSourcePath: sourceRootA, isStrict: false }]); + commandStub.onSecondCall().resolves([{ testSourcePath: sourceRootB, isStrict: false }]); + try { + assert.deepStrictEqual(await testSourceProvider.getAdditionalTestSourcePaths(workspaceFolderA), [sourceRootA]); + assert.deepStrictEqual(await testSourceProvider.getAdditionalTestSourcePaths(workspaceFolderB), [sourceRootB]); + assert.strictEqual(commandStub.callCount, 2); + } finally { + testSourceProvider.clear(); + sinon.restore(); + fse.removeSync(workspacePath); + } + }); + + test('supports Windows separators in source-root patterns', async () => { + const workspacePath: string = fse.mkdtempSync(path.join(os.tmpdir(), 'java-test-source-paths-')); + const sourceRoot: string = path.join(workspacePath, 'modules', 'app', 'src', 'main', 'java'); + fse.ensureDirSync(sourceRoot); + + try { + const paths: string[] = await resolveAdditionalTestSourcePaths(workspacePath, [ + 'modules\\*\\src\\main\\java', + ], [sourceRoot]); + + assert.deepStrictEqual(paths, [sourceRoot]); + } finally { + fse.removeSync(workspacePath); + } + }); + + test('ignores unmatched source-root patterns', async () => { + const workspacePath: string = fse.mkdtempSync(path.join(os.tmpdir(), 'java-test-source-paths-')); + try { + const paths: string[] = await resolveAdditionalTestSourcePaths(workspacePath, [ + 'modules/*/src/test/java', + ], []); + + assert.deepStrictEqual(paths, []); + } finally { + fse.removeSync(workspacePath); + } + }); + + test('ignores literal paths that are not imported source roots', async () => { + const workspacePath: string = fse.mkdtempSync(path.join(os.tmpdir(), 'java-test-source-paths-')); + try { + const paths: string[] = await resolveAdditionalTestSourcePaths(workspacePath, + ['modules/missing/src/main/java'], []); + + assert.deepStrictEqual(paths, []); + } finally { + fse.removeSync(workspacePath); + } + }); + + test('ignores blank paths', async () => { + assert.deepStrictEqual(await resolveAdditionalTestSourcePaths(path.resolve('workspace'), [' ', '\t'], []), []); + }); +});