From fe6d1b19216f0e0e0d245af6382e5584cd37a258 Mon Sep 17 00:00:00 2001 From: Vinay Kumar Maheshwaram <54681400+sudovinay01@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:27:57 +0530 Subject: [PATCH 1/8] feat: support additional Java test source paths --- package.json | 9 ++++ package.nls.json | 1 + package.nls.zh.json | 1 + src/controller/testController.ts | 25 ++++++++++- src/provider/testSourceProvider.ts | 63 ++++++++++++++++++++++++++- test/suite/testSourceProvider.test.ts | 49 +++++++++++++++++++++ 6 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 test/suite/testSourceProvider.test.ts diff --git a/package.json b/package.json index ff762787d..c1e3cdf16 100644 --- a/package.json +++ b/package.json @@ -196,6 +196,15 @@ "description": "%configuration.java.test.defaultConfig.description%", "scope": "resource" }, + "java.test.additionalTestSourcePaths": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "%configuration.java.test.additionalTestSourcePaths.description%", + "scope": "resource" + }, "java.test.config": { "type": [ "object", diff --git a/package.nls.json b/package.nls.json index 312af320b..6323173fc 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": "Additional source paths to scan for Java tests.", "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..242314e59 100644 --- a/package.nls.zh.json +++ b/package.nls.zh.json @@ -1,4 +1,5 @@ { + "configuration.java.test.additionalTestSourcePaths.description": "用于搜索 Java 测试的其他源路径。", "description": "运行并调试 JUnit 或 TestNG 测试用例", "contributes.commands.java.test.editor.run.title": "运行测试用例", "contributes.commands.java.test.editor.debug.title": "调试测试用例", diff --git a/src/controller/testController.ts b/src/controller/testController.ts index de3b3c71e..e133c5921 100644 --- a/src/controller/testController.ts +++ b/src/controller/testController.ts @@ -17,7 +17,7 @@ import { IJavaTestItem } from '../types'; import { loadRunConfig } from '../utils/configUtils'; import { resolveLaunchConfigurationForRunner } from '../utils/launchUtils'; import { dataCache, getResolutionVersion, invalidateResolutionVersion, ITestItemData } from './testItemDataCache'; -import { createTestItem, findDirectTestChildrenForClass, findTestPackagesAndTypes, findTestTypesAndMethods, loadJavaProjects, removeOutdatedTestItemsForDocument, resolvePath, synchronizeItemsRecursively, updateItemForDocumentWithDebounce } from './utils'; +import { createTestItem, findDirectTestChildrenForClass, findTestPackagesAndTypes, findTestTypesAndMethods, loadJavaProjects, removeOutdatedTestItemsForDocument, resolvePath, synchronizeItemsRecursively, updateItemForDocument, updateItemForDocumentWithDebounce } from './utils'; import { JavaTestCoverageProvider } from '../provider/JavaTestCoverageProvider'; import { testRunnerService } from './testRunnerService'; import { IRunTestContext, TestRunner, TestFinishEvent, TestItemStatusChangeEvent, TestKind, TestLevel, TestResultState, TestIdParts } from '../java-test-runner.api'; @@ -207,6 +207,29 @@ async function startWatchingWorkspace(): Promise { }), ); } + + const scannedFiles: Set = new Set(); + for (const sourcePath of testSourceProvider.getAdditionalTestSourcePaths(workspaceFolder)) { + let javaFiles: Uri[]; + try { + javaFiles = await workspace.findFiles(new RelativePattern(Uri.file(sourcePath), '**/*.java')); + } catch { + continue; + } + + for (const javaFile of javaFiles) { + const fileKey: string = process.platform === 'win32' ? javaFile.fsPath.toLowerCase() : javaFile.fsPath; + if (scannedFiles.has(fileKey)) { + continue; + } + scannedFiles.add(fileKey); + + const testTypes: IJavaTestItem[] = await findTestTypesAndMethods(javaFile.toString()); + if (testTypes.length > 0) { + await updateItemForDocument(javaFile, testTypes); + } + } + } } } diff --git a/src/provider/testSourceProvider.ts b/src/provider/testSourceProvider.ts index d9fab1e46..c7b870012 100644 --- a/src/provider/testSourceProvider.ts +++ b/src/provider/testSourceProvider.ts @@ -31,6 +31,12 @@ class TestSourcePathProvider { .map((s: ITestSourcePath) => s.testSourcePath); } + public getAdditionalTestSourcePaths(workspaceFolder: WorkspaceFolder): string[] { + const configuredPaths: string[] = workspace.getConfiguration('java.test', workspaceFolder.uri) + .get('additionalTestSourcePaths', []); + return resolveAdditionalTestSourcePaths(workspaceFolder.uri.fsPath, configuredPaths); + } + public async isOnTestSourcePath(uri: Uri): Promise { const workspaceFolder: WorkspaceFolder | undefined = workspace.getWorkspaceFolder(uri); if (!workspaceFolder) { @@ -61,8 +67,61 @@ class TestSourcePathProvider { testPaths = await getTestSourcePaths([workspaceFolder.uri.toString()]); this.testSourceMapping.set(workspaceFolder.uri, testPaths); } - return testPaths; + + return mergeTestSourcePaths(testPaths, this.getAdditionalTestSourcePaths(workspaceFolder)); + } +} + +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; + } + + 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 function resolveAdditionalTestSourcePaths(workspacePath: string, configuredPaths: string[]): string[] { + const paths: string[] = []; + const pathKeys: Set = new Set(); + for (const configuredPath of configuredPaths) { + if (!configuredPath.trim()) { + continue; + } + + const resolvedPath: string = path.resolve(workspacePath, configuredPath.trim()); + const key: string = getPathKey(resolvedPath); + if (!pathKeys.has(key)) { + paths.push(resolvedPath); + pathKeys.add(key); + } } + return paths; +} + +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 +129,7 @@ async function getTestSourcePaths(uri: string[]): Promise { JavaTestRunnerDelegateCommands.GET_TEST_SOURCE_PATH, uri) || []; } -interface ITestSourcePath { +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/testSourceProvider.test.ts b/test/suite/testSourceProvider.test.ts new file mode 100644 index 000000000..02fa5f57b --- /dev/null +++ b/test/suite/testSourceProvider.test.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as assert from 'assert'; +import * as path from 'path'; +import { ITestSourcePath, mergeTestSourcePaths, resolveAdditionalTestSourcePaths } from '../../src/provider/testSourceProvider'; + +suite('testSourceProvider', () => { + test('returns no extra paths by default', () => { + assert.deepStrictEqual(resolveAdditionalTestSourcePaths(path.resolve('workspace'), []), []); + }); + + test('resolves multiple relative paths against the workspace folder', () => { + const workspacePath: string = path.resolve('workspace'); + const sourcePaths: string[] = resolveAdditionalTestSourcePaths(workspacePath, [ + path.join('src', 'main', 'java'), + path.join('src', 'integrationTest', 'java'), + ]); + + assert.deepStrictEqual(sourcePaths, [ + path.resolve(workspacePath, 'src', 'main', 'java'), + path.resolve(workspacePath, 'src', 'integrationTest', 'java'), + ]); + }); + + test('deduplicates equivalent Windows path spellings', () => { + const workspacePath: string = path.resolve('workspace'); + const relativePath: string = path.join('src', 'main', 'java'); + const sourcePaths: string[] = resolveAdditionalTestSourcePaths(workspacePath, [ + relativePath, + relativePath.replace(/\\/g, '/'), + path.resolve(workspacePath, relativePath), + ]); + + assert.deepStrictEqual(sourcePaths, [path.resolve(workspacePath, relativePath)]); + }); + + 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('ignores blank paths', () => { + assert.deepStrictEqual(resolveAdditionalTestSourcePaths(path.resolve('workspace'), [' ', '\t']), []); + }); +}); \ No newline at end of file From a8f5de68aee86cd2f0923b17249b0271ab836772 Mon Sep 17 00:00:00 2001 From: Vinay Kumar Maheshwaram <54681400+sudovinay01@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:57:57 +0530 Subject: [PATCH 2/8] feat: support glob patterns for test source paths --- package.nls.json | 2 +- package.nls.zh.json | 2 +- src/controller/testController.ts | 2 +- src/provider/testSourceProvider.ts | 47 ++++++++++++++---- test/suite/testSourceProvider.test.ts | 69 +++++++++++++++++++++++---- 5 files changed, 101 insertions(+), 21 deletions(-) diff --git a/package.nls.json b/package.nls.json index 6323173fc..b72dd5104 100644 --- a/package.nls.json +++ b/package.nls.json @@ -7,7 +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": "Additional source paths to scan for Java tests.", + "configuration.java.test.additionalTestSourcePaths.description": "Additional source paths or glob patterns to scan for Java tests.", "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 242314e59..7f2c49cd6 100644 --- a/package.nls.zh.json +++ b/package.nls.zh.json @@ -1,5 +1,5 @@ { - "configuration.java.test.additionalTestSourcePaths.description": "用于搜索 Java 测试的其他源路径。", + "configuration.java.test.additionalTestSourcePaths.description": "用于搜索 Java 测试的其他源路径或 glob 模式。", "description": "运行并调试 JUnit 或 TestNG 测试用例", "contributes.commands.java.test.editor.run.title": "运行测试用例", "contributes.commands.java.test.editor.debug.title": "调试测试用例", diff --git a/src/controller/testController.ts b/src/controller/testController.ts index e133c5921..341dff74f 100644 --- a/src/controller/testController.ts +++ b/src/controller/testController.ts @@ -209,7 +209,7 @@ async function startWatchingWorkspace(): Promise { } const scannedFiles: Set = new Set(); - for (const sourcePath of testSourceProvider.getAdditionalTestSourcePaths(workspaceFolder)) { + for (const sourcePath of await testSourceProvider.getAdditionalTestSourcePaths(workspaceFolder)) { let javaFiles: Uri[]; try { javaFiles = await workspace.findFiles(new RelativePattern(Uri.file(sourcePath), '**/*.java')); diff --git a/src/provider/testSourceProvider.ts b/src/provider/testSourceProvider.ts index c7b870012..ea7c4972d 100644 --- a/src/provider/testSourceProvider.ts +++ b/src/provider/testSourceProvider.ts @@ -2,12 +2,14 @@ // Licensed under the MIT license. import * as path from 'path'; +import { glob, hasMagic } from 'glob'; import { RelativePattern, Uri, workspace, WorkspaceFolder } from 'vscode'; import { JavaTestRunnerDelegateCommands } from '../constants'; import { executeJavaLanguageServerCommand } from '../utils/commandUtils'; class TestSourcePathProvider { private testSourceMapping: Map = new Map(); + private additionalTestSourceMapping: Map> = new Map(); public async getTestSourcePattern(workspaceFolder: WorkspaceFolder, containsGeneral: boolean = true): Promise { const patterns: RelativePattern[] = []; @@ -31,10 +33,15 @@ class TestSourcePathProvider { .map((s: ITestSourcePath) => s.testSourcePath); } - public getAdditionalTestSourcePaths(workspaceFolder: WorkspaceFolder): string[] { + public getAdditionalTestSourcePaths(workspaceFolder: WorkspaceFolder): Promise { const configuredPaths: string[] = workspace.getConfiguration('java.test', workspaceFolder.uri) .get('additionalTestSourcePaths', []); - return resolveAdditionalTestSourcePaths(workspaceFolder.uri.fsPath, configuredPaths); + let resolvedPaths: Promise | undefined = this.additionalTestSourceMapping.get(workspaceFolder.uri); + if (!resolvedPaths) { + resolvedPaths = resolveAdditionalTestSourcePaths(workspaceFolder.uri.fsPath, configuredPaths); + this.additionalTestSourceMapping.set(workspaceFolder.uri, resolvedPaths); + } + return resolvedPaths; } public async isOnTestSourcePath(uri: Uri): Promise { @@ -55,9 +62,11 @@ class TestSourcePathProvider { public clear(): void { this.testSourceMapping.clear(); + this.additionalTestSourceMapping.clear(); } public delete(workspaceUri: Uri): boolean { + this.additionalTestSourceMapping.delete(workspaceUri); return this.testSourceMapping.delete(workspaceUri); } @@ -68,7 +77,7 @@ class TestSourcePathProvider { this.testSourceMapping.set(workspaceFolder.uri, testPaths); } - return mergeTestSourcePaths(testPaths, this.getAdditionalTestSourcePaths(workspaceFolder)); + return mergeTestSourcePaths(testPaths, await this.getAdditionalTestSourcePaths(workspaceFolder)); } } @@ -101,7 +110,7 @@ export function mergeTestSourcePaths(testPaths: ITestSourcePath[], additionalPat return mergedPaths; } -export function resolveAdditionalTestSourcePaths(workspacePath: string, configuredPaths: string[]): string[] { +export async function resolveAdditionalTestSourcePaths(workspacePath: string, configuredPaths: string[]): Promise { const paths: string[] = []; const pathKeys: Set = new Set(); for (const configuredPath of configuredPaths) { @@ -109,16 +118,36 @@ export function resolveAdditionalTestSourcePaths(workspacePath: string, configur continue; } - const resolvedPath: string = path.resolve(workspacePath, configuredPath.trim()); - const key: string = getPathKey(resolvedPath); - if (!pathKeys.has(key)) { - paths.push(resolvedPath); - pathKeys.add(key); + const normalizedPattern: string = configuredPath.trim().replace(/\\/g, '/'); + const resolvedPattern: string = path.resolve(workspacePath, normalizedPattern); + if (!hasMagic(normalizedPattern)) { + addUniquePath(paths, pathKeys, resolvedPattern); + continue; + } + + try { + const matches: { isDirectory(): boolean; fullpath(): string }[] = + await glob(resolvedPattern.replace(/\\/g, '/'), { withFileTypes: true }); + for (const match of matches) { + if (match.isDirectory()) { + addUniquePath(paths, pathKeys, match.fullpath()); + } + } + } catch { + // Invalid or inaccessible patterns should not prevent test discovery for other paths. } } return paths; } +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; diff --git a/test/suite/testSourceProvider.test.ts b/test/suite/testSourceProvider.test.ts index 02fa5f57b..0926814d5 100644 --- a/test/suite/testSourceProvider.test.ts +++ b/test/suite/testSourceProvider.test.ts @@ -2,17 +2,19 @@ // 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 { ITestSourcePath, mergeTestSourcePaths, resolveAdditionalTestSourcePaths } from '../../src/provider/testSourceProvider'; suite('testSourceProvider', () => { - test('returns no extra paths by default', () => { - assert.deepStrictEqual(resolveAdditionalTestSourcePaths(path.resolve('workspace'), []), []); + test('returns no extra paths by default', async () => { + assert.deepStrictEqual(await resolveAdditionalTestSourcePaths(path.resolve('workspace'), []), []); }); - test('resolves multiple relative paths against the workspace folder', () => { + test('resolves multiple relative paths against the workspace folder', async () => { const workspacePath: string = path.resolve('workspace'); - const sourcePaths: string[] = resolveAdditionalTestSourcePaths(workspacePath, [ + const sourcePaths: string[] = await resolveAdditionalTestSourcePaths(workspacePath, [ path.join('src', 'main', 'java'), path.join('src', 'integrationTest', 'java'), ]); @@ -23,10 +25,10 @@ suite('testSourceProvider', () => { ]); }); - test('deduplicates equivalent Windows path spellings', () => { + test('deduplicates equivalent Windows path spellings', async () => { const workspacePath: string = path.resolve('workspace'); const relativePath: string = path.join('src', 'main', 'java'); - const sourcePaths: string[] = resolveAdditionalTestSourcePaths(workspacePath, [ + const sourcePaths: string[] = await resolveAdditionalTestSourcePaths(workspacePath, [ relativePath, relativePath.replace(/\\/g, '/'), path.resolve(workspacePath, relativePath), @@ -43,7 +45,56 @@ suite('testSourceProvider', () => { assert.deepStrictEqual(paths, [{ testSourcePath: sourcePath, isStrict: true }]); }); - test('ignores blank paths', () => { - assert.deepStrictEqual(resolveAdditionalTestSourcePaths(path.resolve('workspace'), [' ', '\t']), []); + 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', + ]); + + assert.deepStrictEqual(paths.sort(), sourceRoots.sort()); + } finally { + 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', + ]); + + 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 blank paths', async () => { + assert.deepStrictEqual(await resolveAdditionalTestSourcePaths(path.resolve('workspace'), [' ', '\t']), []); }); -}); \ No newline at end of file +}); From ea6719a7a92cb4ff9145bdbd14f7cba95dc31c1d Mon Sep 17 00:00:00 2001 From: Vinay Kumar Maheshwaram <54681400+sudovinay01@users.noreply.github.com> Date: Sun, 27 Sep 2026 08:27:03 +0530 Subject: [PATCH 3/8] fix: refresh test discovery when source paths change --- src/controller/testController.ts | 12 ++++++++---- src/extension.ts | 9 +++++++-- test/suite/testController.loadChildren.test.ts | 15 ++++++++++++++- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/controller/testController.ts b/src/controller/testController.ts index 341dff74f..707002e53 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,12 @@ export function creatTestProfile(name: string, kind: TestRunProfileKind): void { testController?.createRunProfile(name, kind, runHandler, false, runnableTag); } +export async function refreshTestSourcePaths(): Promise { + testSourceProvider.clear(); + await refreshExplorer(); + await startWatchingWorkspace(); +} + 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(); @@ -153,7 +157,7 @@ async function startWatchingWorkspace(): Promise { return; } - for (const disposable of watchers) { + for (const disposable of watchers.splice(0)) { disposable.dispose(); } diff --git a/src/extension.ts b/src/extension.ts index 18a786c79..2bc9cfb38 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, WorkspaceFoldersChangeEvent } 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'; @@ -131,6 +131,11 @@ function registerComponents(context: ExtensionContext): void { await updateItemForDocumentWithDebounce(e.document.uri); } }), + workspace.onDidChangeConfiguration(async (event: ConfigurationChangeEvent) => { + if (event.affectsConfiguration('java.test.additionalTestSourcePaths')) { + await refreshTestSourcePaths(); + } + }), workspace.onDidChangeWorkspaceFolders(async (e: WorkspaceFoldersChangeEvent) => { for (const deletedFolder of e.removed) { testSourceProvider.delete(deletedFolder.uri); diff --git a/test/suite/testController.loadChildren.test.ts b/test/suite/testController.loadChildren.test.ts index cdf54e51a..ed26b6f75 100644 --- a/test/suite/testController.loadChildren.test.ts +++ b/test/suite/testController.loadChildren.test.ts @@ -4,11 +4,13 @@ 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 { 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,17 @@ 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([]); + const additionalPathsStub = sinon.stub(testSourceProvider, 'getAdditionalTestSourcePaths').resolves([]); + + await refreshTestSourcePaths(); + + sinon.assert.callOrder(clearPathsStub, refreshExplorerStub, watcherPatternsStub, additionalPathsStub); + }); + 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([]); From c6407685ea8d235f46378e67707af1f69501a96b Mon Sep 17 00:00:00 2001 From: Vinay Kumar Maheshwaram <54681400+sudovinay01@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:16:53 +0530 Subject: [PATCH 4/8] fix: address source path review feedback --- package-lock.json | 9 +--- package.json | 3 +- src/controller/testController.ts | 29 +++++++++-- src/extension.ts | 1 + src/provider/testSourceProvider.ts | 45 ++++++++++++----- .../suite/testController.loadChildren.test.ts | 45 ++++++++++++++++- test/suite/testSourceProvider.test.ts | 50 ++++++++++++------- 7 files changed, 139 insertions(+), 43 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2240cba23..4aa3bfc4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.46.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", @@ -1973,7 +1971,6 @@ "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, "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 c1e3cdf16..2f6a83f40 100644 --- a/package.json +++ b/package.json @@ -201,6 +201,7 @@ "items": { "type": "string" }, + "uniqueItems": true, "default": [], "description": "%configuration.java.test.additionalTestSourcePaths.description%", "scope": "resource" @@ -572,7 +573,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", @@ -582,6 +582,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/src/controller/testController.ts b/src/controller/testController.ts index 707002e53..24b0f0ef5 100644 --- a/src/controller/testController.ts +++ b/src/controller/testController.ts @@ -27,6 +27,7 @@ import { parsePartsFromTestId } from '../utils/testItemUtils'; export let testController: TestController | undefined; export const watchers: Disposable[] = []; export const runnableTag: TestTag = new TestTag('runnable'); +const MAX_ADDITIONAL_TEST_DISCOVERY_CONCURRENCY: number = 4; const pendingTestItemResolutions: WeakMap> = new WeakMap(); export function createTestController(): void { @@ -157,6 +158,7 @@ async function startWatchingWorkspace(): Promise { return; } + // Remove disposed handles so repeated refreshes do not retain stale watchers. for (const disposable of watchers.splice(0)) { disposable.dispose(); } @@ -221,22 +223,43 @@ async function startWatchingWorkspace(): Promise { continue; } + const filesToScan: Uri[] = []; for (const javaFile of javaFiles) { const fileKey: string = process.platform === 'win32' ? javaFile.fsPath.toLowerCase() : javaFile.fsPath; if (scannedFiles.has(fileKey)) { continue; } scannedFiles.add(fileKey); + filesToScan.push(javaFile); + } - const testTypes: IJavaTestItem[] = await findTestTypesAndMethods(javaFile.toString()); - if (testTypes.length > 0) { - await updateItemForDocument(javaFile, testTypes); + const discoveries: IAdditionalTestFileDiscovery[] = await discoverAdditionalTestTypes(filesToScan); + for (const discovery of discoveries) { + if (discovery.testTypes.length > 0) { + await updateItemForDocument(discovery.uri, discovery.testTypes); } } } } } +export interface IAdditionalTestFileDiscovery { + uri: Uri; + testTypes: IJavaTestItem[]; +} + +export async function discoverAdditionalTestTypes(javaFiles: Uri[]): Promise { + const discoveries: IAdditionalTestFileDiscovery[] = []; + for (const fileBatch of _.chunk(javaFiles, MAX_ADDITIONAL_TEST_DISCOVERY_CONCURRENCY)) { + const testTypesByFile: IJavaTestItem[][] = await Promise.all(fileBatch.map( + async (javaFile: Uri): Promise => await findTestTypesAndMethods(javaFile.toString()))); + for (let index: number = 0; index < fileBatch.length; index++) { + discoveries.push({ uri: fileBatch[index], testTypes: testTypesByFile[index] }); + } + } + return discoveries; +} + async function runHandler(request: TestRunRequest, token: CancellationToken): Promise { await runTests(request, { token, isDebug: !!request.profile?.label.includes('Debug') }); } diff --git a/src/extension.ts b/src/extension.ts index 2bc9cfb38..92b32ebb3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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(); diff --git a/src/provider/testSourceProvider.ts b/src/provider/testSourceProvider.ts index ea7c4972d..8ca2a06db 100644 --- a/src/provider/testSourceProvider.ts +++ b/src/provider/testSourceProvider.ts @@ -2,14 +2,15 @@ // Licensed under the MIT license. import * as path from 'path'; +import * as fse from 'fs-extra'; import { glob, hasMagic } from 'glob'; -import { RelativePattern, Uri, workspace, WorkspaceFolder } from 'vscode'; +import { 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 additionalTestSourceMapping: 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[] = []; @@ -36,10 +37,11 @@ class TestSourcePathProvider { public getAdditionalTestSourcePaths(workspaceFolder: WorkspaceFolder): Promise { const configuredPaths: string[] = workspace.getConfiguration('java.test', workspaceFolder.uri) .get('additionalTestSourcePaths', []); - let resolvedPaths: Promise | undefined = this.additionalTestSourceMapping.get(workspaceFolder.uri); + const workspaceKey: string = workspaceFolder.uri.toString(); + let resolvedPaths: Promise | undefined = this.additionalTestSourceMapping.get(workspaceKey); if (!resolvedPaths) { resolvedPaths = resolveAdditionalTestSourcePaths(workspaceFolder.uri.fsPath, configuredPaths); - this.additionalTestSourceMapping.set(workspaceFolder.uri, resolvedPaths); + this.additionalTestSourceMapping.set(workspaceKey, resolvedPaths); } return resolvedPaths; } @@ -66,21 +68,30 @@ class TestSourcePathProvider { } public delete(workspaceUri: Uri): boolean { - this.additionalTestSourceMapping.delete(workspaceUri); - return this.testSourceMapping.delete(workspaceUri); + const workspaceKey: string = workspaceUri.toString(); + this.additionalTestSourceMapping.delete(workspaceKey); + return this.testSourceMapping.delete(workspaceKey); + } + + 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: ITestSourcePath[] | undefined = this.testSourceMapping.get(workspaceKey); if (!testPaths) { testPaths = await getTestSourcePaths([workspaceFolder.uri.toString()]); - this.testSourceMapping.set(workspaceFolder.uri, testPaths); + this.testSourceMapping.set(workspaceKey, testPaths); } return mergeTestSourcePaths(testPaths, await this.getAdditionalTestSourcePaths(workspaceFolder)); } } +let additionalTestSourceOutputChannel: OutputChannel | undefined; + export function mergeTestSourcePaths(testPaths: ITestSourcePath[], additionalPaths: string[]): ITestSourcePath[] { const mergedPaths: ITestSourcePath[] = []; const pathIndexes: Map = new Map(); @@ -121,7 +132,14 @@ export async function resolveAdditionalTestSourcePaths(workspacePath: string, co const normalizedPattern: string = configuredPath.trim().replace(/\\/g, '/'); const resolvedPattern: string = path.resolve(workspacePath, normalizedPattern); if (!hasMagic(normalizedPattern)) { - addUniquePath(paths, pathKeys, resolvedPattern); + try { + const stats: fse.Stats = await fse.stat(resolvedPattern); + if (stats.isDirectory()) { + addUniquePath(paths, pathKeys, resolvedPattern); + } + } catch { + // Ignore missing or inaccessible literal paths. + } continue; } @@ -133,8 +151,11 @@ export async function resolveAdditionalTestSourcePaths(workspacePath: string, co addUniquePath(paths, pathKeys, match.fullpath()); } } - } catch { - // Invalid or inaccessible patterns should not prevent test discovery for other paths. + } catch (error) { + const message: string = error instanceof Error ? error.message : String(error); + additionalTestSourceOutputChannel ??= window.createOutputChannel('Test Runner for Java'); + additionalTestSourceOutputChannel.appendLine( + `Failed to expand additional test source pattern "${configuredPath}": ${message}`); } } return paths; diff --git a/test/suite/testController.loadChildren.test.ts b/test/suite/testController.loadChildren.test.ts index ed26b6f75..be7b12411 100644 --- a/test/suite/testController.loadChildren.test.ts +++ b/test/suite/testController.loadChildren.test.ts @@ -3,8 +3,8 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; -import { CancellationTokenSource, TestController, TestItem, tests, Uri } from 'vscode'; -import { loadChildren, refreshTestSourcePaths } from '../../src/controller/testController'; +import { CancellationTokenSource, TestController, TestItem, tests, Uri, workspace } from 'vscode'; +import { discoverAdditionalTestTypes, 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'; @@ -56,6 +56,47 @@ suite('testController - loadChildren', () => { sinon.assert.callOrder(clearPathsStub, refreshExplorerStub, watcherPatternsStub, additionalPathsStub); }); + test('scans Java files found under configured additional source roots', async () => { + const javaFile: Uri = Uri.file('/workspace/additional/src/MainSourceTest.java'); + const refreshExplorerStub = sinon.stub(testExplorerCommands, 'refreshExplorer').resolves(); + const watcherPatternsStub = sinon.stub(testSourceProvider, 'getTestSourcePattern').resolves([]); + const additionalPathsStub = sinon.stub(testSourceProvider, 'getAdditionalTestSourcePaths') + .resolves(['/workspace/additional/src']); + const findFilesStub = sinon.stub(workspace, 'findFiles').resolves([javaFile]); + const findTestsStub = sinon.stub(controllerUtils, 'findTestTypesAndMethods').resolves([]); + + await refreshTestSourcePaths(); + + assert.ok(refreshExplorerStub.calledOnce); + assert.ok(watcherPatternsStub.calledOnce); + assert.ok(additionalPathsStub.calledOnce); + assert.ok(findFilesStub.calledOnce); + sinon.assert.calledOnceWithExactly(findTestsStub, javaFile.toString()); + }); + + test('bounds concurrent additional-source discovery and preserves result order', async () => { + let activeRequests: number = 0; + let maximumActiveRequests: number = 0; + const findTestsStub = sinon.stub(controllerUtils, 'findTestTypesAndMethods').callsFake(async () => { + activeRequests++; + maximumActiveRequests = Math.max(maximumActiveRequests, activeRequests); + await new Promise((resolve) => setTimeout(resolve, 5)); + activeRequests--; + return []; + }); + const javaFiles: Uri[] = []; + for (let index: number = 0; index < 10; index++) { + javaFiles.push(Uri.file(`/workspace/src/Test${index}.java`)); + } + + const discoveries = await discoverAdditionalTestTypes(javaFiles); + + assert.strictEqual(findTestsStub.callCount, javaFiles.length); + assert.strictEqual(maximumActiveRequests, 4); + assert.deepStrictEqual(discoveries.map((discovery) => discovery.uri.toString()), + javaFiles.map((javaFile: Uri) => javaFile.toString())); + }); + 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([]); diff --git a/test/suite/testSourceProvider.test.ts b/test/suite/testSourceProvider.test.ts index 0926814d5..f6b63b675 100644 --- a/test/suite/testSourceProvider.test.ts +++ b/test/suite/testSourceProvider.test.ts @@ -13,28 +13,33 @@ suite('testSourceProvider', () => { }); test('resolves multiple relative paths against the workspace folder', async () => { - const workspacePath: string = path.resolve('workspace'); - const sourcePaths: string[] = await resolveAdditionalTestSourcePaths(workspacePath, [ - path.join('src', 'main', 'java'), - path.join('src', 'integrationTest', 'java'), - ]); - - assert.deepStrictEqual(sourcePaths, [ - path.resolve(workspacePath, 'src', 'main', 'java'), - path.resolve(workspacePath, 'src', 'integrationTest', 'java'), - ]); + 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); + + 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 = path.resolve('workspace'); + const workspacePath: string = fse.mkdtempSync(path.join(os.tmpdir(), 'java-test-source-paths-')); const relativePath: string = path.join('src', 'main', 'java'); - const sourcePaths: string[] = await resolveAdditionalTestSourcePaths(workspacePath, [ - relativePath, - relativePath.replace(/\\/g, '/'), - path.resolve(workspacePath, relativePath), - ]); + fse.ensureDirSync(path.resolve(workspacePath, relativePath)); + try { + const sourcePaths: string[] = await resolveAdditionalTestSourcePaths(workspacePath, [ + relativePath, + relativePath.replace(/\\/g, '/'), + path.resolve(workspacePath, relativePath), + ]); - assert.deepStrictEqual(sourcePaths, [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', () => { @@ -94,6 +99,17 @@ suite('testSourceProvider', () => { } }); + test('ignores literal paths that do not exist as directories', 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']), []); }); From 6635cc09d7f220cddbc3a6b144d58be7dfccc802 Mon Sep 17 00:00:00 2001 From: Vinay Kumar Maheshwaram <54681400+sudovinay01@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:09:05 +0530 Subject: [PATCH 5/8] fix: harden additional test source discovery --- package.nls.json | 2 +- package.nls.zh.json | 2 +- src/controller/testController.ts | 22 ++++++++-- src/provider/testSourceProvider.ts | 15 +++++-- .../suite/testController.loadChildren.test.ts | 31 ++++++++++++++ test/suite/testSourceProvider.test.ts | 40 ++++++++++++++++++- 6 files changed, 102 insertions(+), 10 deletions(-) diff --git a/package.nls.json b/package.nls.json index b72dd5104..97f41ab37 100644 --- a/package.nls.json +++ b/package.nls.json @@ -7,7 +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": "Additional source paths or glob patterns to scan for Java tests.", + "configuration.java.test.additionalTestSourcePaths.description": "Additional source paths or glob patterns to scan for Java tests. Broad patterns may slow discovery.", "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 7f2c49cd6..55a86440a 100644 --- a/package.nls.zh.json +++ b/package.nls.zh.json @@ -1,5 +1,5 @@ { - "configuration.java.test.additionalTestSourcePaths.description": "用于搜索 Java 测试的其他源路径或 glob 模式。", + "configuration.java.test.additionalTestSourcePaths.description": "用于搜索 Java 测试的其他源路径或 glob 模式。过于宽泛的模式可能会降低发现速度。", "description": "运行并调试 JUnit 或 TestNG 测试用例", "contributes.commands.java.test.editor.run.title": "运行测试用例", "contributes.commands.java.test.editor.debug.title": "调试测试用例", diff --git a/src/controller/testController.ts b/src/controller/testController.ts index 24b0f0ef5..7df16df0d 100644 --- a/src/controller/testController.ts +++ b/src/controller/testController.ts @@ -51,10 +51,24 @@ export function creatTestProfile(name: string, kind: TestRunProfileKind): void { testController?.createRunProfile(name, kind, runHandler, false, runnableTag); } -export async function refreshTestSourcePaths(): Promise { - testSourceProvider.clear(); - await refreshExplorer(); - await startWatchingWorkspace(); +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) => { diff --git a/src/provider/testSourceProvider.ts b/src/provider/testSourceProvider.ts index 8ca2a06db..a945b62f8 100644 --- a/src/provider/testSourceProvider.ts +++ b/src/provider/testSourceProvider.ts @@ -91,6 +91,7 @@ class TestSourcePathProvider { } let additionalTestSourceOutputChannel: OutputChannel | undefined; +const LARGE_ADDITIONAL_TEST_SOURCE_COUNT: number = 100; export function mergeTestSourcePaths(testPaths: ITestSourcePath[], additionalPaths: string[]): ITestSourcePath[] { const mergedPaths: ITestSourcePath[] = []; @@ -153,14 +154,22 @@ export async function resolveAdditionalTestSourcePaths(workspacePath: string, co } } catch (error) { const message: string = error instanceof Error ? error.message : String(error); - additionalTestSourceOutputChannel ??= window.createOutputChannel('Test Runner for Java'); - additionalTestSourceOutputChannel.appendLine( - `Failed to expand additional test source pattern "${configuredPath}": ${message}`); + logAdditionalTestSourceMessage(`Failed to expand additional test source pattern "${configuredPath}": ${message}`); } } + + if (paths.length > LARGE_ADDITIONAL_TEST_SOURCE_COUNT) { + logAdditionalTestSourceMessage( + `Additional test source patterns matched ${paths.length} directories. 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)) { diff --git a/test/suite/testController.loadChildren.test.ts b/test/suite/testController.loadChildren.test.ts index be7b12411..cda0f6f1e 100644 --- a/test/suite/testController.loadChildren.test.ts +++ b/test/suite/testController.loadChildren.test.ts @@ -56,6 +56,37 @@ suite('testController - loadChildren', () => { sinon.assert.callOrder(clearPathsStub, refreshExplorerStub, watcherPatternsStub, additionalPathsStub); }); + 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 additionalPathsStub = sinon.stub(testSourceProvider, 'getAdditionalTestSourcePaths').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); + assert.strictEqual(additionalPathsStub.callCount, 2); + }); + test('scans Java files found under configured additional source roots', async () => { const javaFile: Uri = Uri.file('/workspace/additional/src/MainSourceTest.java'); const refreshExplorerStub = sinon.stub(testExplorerCommands, 'refreshExplorer').resolves(); diff --git a/test/suite/testSourceProvider.test.ts b/test/suite/testSourceProvider.test.ts index f6b63b675..057cb0037 100644 --- a/test/suite/testSourceProvider.test.ts +++ b/test/suite/testSourceProvider.test.ts @@ -5,7 +5,10 @@ import * as assert from 'assert'; import * as fse from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; -import { ITestSourcePath, mergeTestSourcePaths, resolveAdditionalTestSourcePaths } from '../../src/provider/testSourceProvider'; +import * as sinon from 'sinon'; +import { RelativePattern, Uri, WorkspaceConfiguration, WorkspaceFolder, workspace } from 'vscode'; +import * as commandUtils from '../../src/utils/commandUtils'; +import { ITestSourcePath, mergeTestSourcePaths, resolveAdditionalTestSourcePaths, testSourceProvider } from '../../src/provider/testSourceProvider'; suite('testSourceProvider', () => { test('returns no extra paths by default', async () => { @@ -70,6 +73,41 @@ suite('testSourceProvider', () => { } }); + 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([]); + 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('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'); From b0589ee2fb5e5ccecfa7570f8e02b731a823d8b0 Mon Sep 17 00:00:00 2001 From: Vinay Kumar Maheshwaram <54681400+sudovinay01@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:51:39 +0530 Subject: [PATCH 6/8] Implement additional test source discovery Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../projects/preview-junit/.classpath | 3 +- .../projects/preview-junit/.project | 6 + .../projects/preview-junit/pom.xml | 19 +++ .../java/example/AdditionalMainSource.java | 4 + .../example/AdditionalMainSourceTest.java | 10 ++ .../test/plugin/util/TestSearchUtilsTest.java | 29 +++++ .../com.microsoft.java.test.plugin/plugin.xml | 1 + .../handler/TestDelegateCommandHandler.java | 3 + .../test/plugin/util/ProjectTestUtils.java | 79 ++++++++++++ .../test/plugin/util/TestNavigationUtils.java | 16 ++- .../test/plugin/util/TestSearchUtils.java | 72 +++++++---- package.json | 15 +-- package.nls.json | 2 +- package.nls.zh.json | 2 +- scripts/buildJdtlsExt.js | 15 ++- src/commands/navigation/navigationCommands.ts | 15 ++- src/constants.ts | 1 + src/controller/testController.ts | 58 ++------- src/controller/utils.ts | 5 +- src/extension.ts | 8 +- src/provider/testSourceProvider.ts | 60 +++++---- test/suite/navigation.test.ts | 33 ++++- .../suite/testController.loadChildren.test.ts | 109 ++++++++++------ test/suite/testSourceProvider.test.ts | 121 ++++++++++++++++-- 24 files changed, 512 insertions(+), 174 deletions(-) create mode 100644 java-extension/com.microsoft.java.test.plugin.test/projects/preview-junit/pom.xml create mode 100644 java-extension/com.microsoft.java.test.plugin.test/projects/preview-junit/src/main/java/example/AdditionalMainSource.java create mode 100644 java-extension/com.microsoft.java.test.plugin.test/projects/preview-junit/src/main/java/example/AdditionalMainSourceTest.java 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.json b/package.json index 5d34f79e3..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": [ { diff --git a/package.nls.json b/package.nls.json index 97f41ab37..73733ce45 100644 --- a/package.nls.json +++ b/package.nls.json @@ -7,7 +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": "Additional source paths or glob patterns to scan for Java tests. Broad patterns may slow discovery.", + "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 55a86440a..a498c80bd 100644 --- a/package.nls.zh.json +++ b/package.nls.zh.json @@ -1,5 +1,5 @@ { - "configuration.java.test.additionalTestSourcePaths.description": "用于搜索 Java 测试的其他源路径或 glob 模式。过于宽泛的模式可能会降低发现速度。", + "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 7df16df0d..9235488f6 100644 --- a/src/controller/testController.ts +++ b/src/controller/testController.ts @@ -17,7 +17,7 @@ import { IJavaTestItem } from '../types'; import { loadRunConfig } from '../utils/configUtils'; import { resolveLaunchConfigurationForRunner } from '../utils/launchUtils'; import { dataCache, getResolutionVersion, invalidateResolutionVersion, ITestItemData } from './testItemDataCache'; -import { createTestItem, findDirectTestChildrenForClass, findTestPackagesAndTypes, findTestTypesAndMethods, loadJavaProjects, removeOutdatedTestItemsForDocument, resolvePath, synchronizeItemsRecursively, updateItemForDocument, updateItemForDocumentWithDebounce } from './utils'; +import { createTestItem, findDirectTestChildrenForClass, findTestPackagesAndTypes, findTestTypesAndMethods, loadJavaProjects, removeOutdatedTestItemsForDocument, resolvePath, synchronizeItemsRecursively, updateItemForDocumentWithDebounce } from './utils'; import { JavaTestCoverageProvider } from '../provider/JavaTestCoverageProvider'; import { testRunnerService } from './testRunnerService'; import { IRunTestContext, TestRunner, TestFinishEvent, TestItemStatusChangeEvent, TestKind, TestLevel, TestResultState, TestIdParts } from '../java-test-runner.api'; @@ -27,7 +27,6 @@ import { parsePartsFromTestId } from '../utils/testItemUtils'; export let testController: TestController | undefined; export const watchers: Disposable[] = []; export const runnableTag: TestTag = new TestTag('runnable'); -const MAX_ADDITIONAL_TEST_DISCOVERY_CONCURRENCY: number = 4; const pendingTestItemResolutions: WeakMap> = new WeakMap(); export function createTestController(): void { @@ -129,7 +128,17 @@ 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; + const additionalSourcePaths: string[] = workspaceFolder + ? await testSourceProvider.getAdditionalTestSourcePaths(workspaceFolder) + : []; + if (token?.isCancellationRequested || resolutionVersion !== getResolutionVersion(item)) { + return; + } + const packageAndTypes: IJavaTestItem[] = await findTestPackagesAndTypes( + data.jdtHandler, token, additionalSourcePaths); if (token?.isCancellationRequested || resolutionVersion !== getResolutionVersion(item)) { return; } @@ -228,50 +237,7 @@ async function startWatchingWorkspace(): Promise { ); } - const scannedFiles: Set = new Set(); - for (const sourcePath of await testSourceProvider.getAdditionalTestSourcePaths(workspaceFolder)) { - let javaFiles: Uri[]; - try { - javaFiles = await workspace.findFiles(new RelativePattern(Uri.file(sourcePath), '**/*.java')); - } catch { - continue; - } - - const filesToScan: Uri[] = []; - for (const javaFile of javaFiles) { - const fileKey: string = process.platform === 'win32' ? javaFile.fsPath.toLowerCase() : javaFile.fsPath; - if (scannedFiles.has(fileKey)) { - continue; - } - scannedFiles.add(fileKey); - filesToScan.push(javaFile); - } - - const discoveries: IAdditionalTestFileDiscovery[] = await discoverAdditionalTestTypes(filesToScan); - for (const discovery of discoveries) { - if (discovery.testTypes.length > 0) { - await updateItemForDocument(discovery.uri, discovery.testTypes); - } - } - } - } -} - -export interface IAdditionalTestFileDiscovery { - uri: Uri; - testTypes: IJavaTestItem[]; -} - -export async function discoverAdditionalTestTypes(javaFiles: Uri[]): Promise { - const discoveries: IAdditionalTestFileDiscovery[] = []; - for (const fileBatch of _.chunk(javaFiles, MAX_ADDITIONAL_TEST_DISCOVERY_CONCURRENCY)) { - const testTypesByFile: IJavaTestItem[][] = await Promise.all(fileBatch.map( - async (javaFile: Uri): Promise => await findTestTypesAndMethods(javaFile.toString()))); - for (let index: number = 0; index < fileBatch.length; index++) { - discoveries.push({ uri: fileBatch[index], testTypes: testTypesByFile[index] }); - } } - return discoveries; } async function runHandler(request: TestRunRequest, token: CancellationToken): 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 92b32ebb3..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, ConfigurationChangeEvent, 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'; @@ -137,10 +137,8 @@ function registerComponents(context: ExtensionContext): void { await refreshTestSourcePaths(); } }), - workspace.onDidChangeWorkspaceFolders(async (e: WorkspaceFoldersChangeEvent) => { - for (const deletedFolder of e.removed) { - testSourceProvider.delete(deletedFolder.uri); - } + 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 a945b62f8..ca00eb8c3 100644 --- a/src/provider/testSourceProvider.ts +++ b/src/provider/testSourceProvider.ts @@ -2,8 +2,8 @@ // Licensed under the MIT license. import * as path from 'path'; -import * as fse from 'fs-extra'; -import { glob, hasMagic } from 'glob'; +import * as minimatch from 'minimatch'; +import { hasMagic } from 'glob'; import { OutputChannel, RelativePattern, Uri, window, workspace, WorkspaceFolder } from 'vscode'; import { JavaTestRunnerDelegateCommands } from '../constants'; import { executeJavaLanguageServerCommand } from '../utils/commandUtils'; @@ -40,7 +40,11 @@ class TestSourcePathProvider { const workspaceKey: string = workspaceFolder.uri.toString(); let resolvedPaths: Promise | undefined = this.additionalTestSourceMapping.get(workspaceKey); if (!resolvedPaths) { - resolvedPaths = resolveAdditionalTestSourcePaths(workspaceFolder.uri.fsPath, configuredPaths); + resolvedPaths = configuredPaths.length > 0 + ? getSourcePaths([workspaceFolder.uri.toString()]).then((sourcePaths: ITestSourcePath[]) => + resolveAdditionalTestSourcePaths(workspaceFolder.uri.fsPath, configuredPaths, + sourcePaths.map((sourcePath: ITestSourcePath) => sourcePath.testSourcePath))) + : Promise.resolve([]); this.additionalTestSourceMapping.set(workspaceKey, resolvedPaths); } return resolvedPaths; @@ -67,12 +71,6 @@ class TestSourcePathProvider { this.additionalTestSourceMapping.clear(); } - public delete(workspaceUri: Uri): boolean { - const workspaceKey: string = workspaceUri.toString(); - this.additionalTestSourceMapping.delete(workspaceKey); - return this.testSourceMapping.delete(workspaceKey); - } - public dispose(): void { additionalTestSourceOutputChannel?.dispose(); additionalTestSourceOutputChannel = undefined; @@ -122,9 +120,15 @@ export function mergeTestSourcePaths(testPaths: ITestSourcePath[], additionalPat return mergedPaths; } -export async function resolveAdditionalTestSourcePaths(workspacePath: string, configuredPaths: string[]): Promise { +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; @@ -133,34 +137,37 @@ export async function resolveAdditionalTestSourcePaths(workspacePath: string, co const normalizedPattern: string = configuredPath.trim().replace(/\\/g, '/'); const resolvedPattern: string = path.resolve(workspacePath, normalizedPattern); if (!hasMagic(normalizedPattern)) { - try { - const stats: fse.Stats = await fse.stat(resolvedPattern); - if (stats.isDirectory()) { - addUniquePath(paths, pathKeys, resolvedPattern); - } - } catch { - // Ignore missing or inaccessible literal paths. + const sourcePath: string | undefined = sourcePathByKey.get(getPathKey(resolvedPattern)); + if (sourcePath) { + addUniquePath(paths, pathKeys, sourcePath); } continue; } try { - const matches: { isDirectory(): boolean; fullpath(): string }[] = - await glob(resolvedPattern.replace(/\\/g, '/'), { withFileTypes: true }); - for (const match of matches) { - if (match.isDirectory()) { - addUniquePath(paths, pathKeys, match.fullpath()); + 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 expand additional test source pattern "${configuredPath}": ${message}`); + 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} directories. Broad patterns may slow discovery.`); + `Additional test source patterns matched ${paths.length} imported source roots. Broad patterns may slow discovery.`); } return paths; } @@ -188,6 +195,11 @@ async function getTestSourcePaths(uri: string[]): Promise { JavaTestRunnerDelegateCommands.GET_TEST_SOURCE_PATH, uri) || []; } +async function getSourcePaths(uri: string[]): Promise { + return await executeJavaLanguageServerCommand( + JavaTestRunnerDelegateCommands.GET_SOURCE_PATH, uri) || []; +} + export interface ITestSourcePath { testSourcePath: string; /** 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 cda0f6f1e..54c135ea2 100644 --- a/test/suite/testController.loadChildren.test.ts +++ b/test/suite/testController.loadChildren.test.ts @@ -3,8 +3,8 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; -import { CancellationTokenSource, TestController, TestItem, tests, Uri, workspace } from 'vscode'; -import { discoverAdditionalTestTypes, loadChildren, refreshTestSourcePaths } 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'; @@ -49,11 +49,10 @@ suite('testController - loadChildren', () => { const clearPathsStub = sinon.stub(testSourceProvider, 'clear'); const refreshExplorerStub = sinon.stub(testExplorerCommands, 'refreshExplorer').resolves(); const watcherPatternsStub = sinon.stub(testSourceProvider, 'getTestSourcePattern').resolves([]); - const additionalPathsStub = sinon.stub(testSourceProvider, 'getAdditionalTestSourcePaths').resolves([]); await refreshTestSourcePaths(); - sinon.assert.callOrder(clearPathsStub, refreshExplorerStub, watcherPatternsStub, additionalPathsStub); + sinon.assert.callOrder(clearPathsStub, refreshExplorerStub, watcherPatternsStub); }); test('serializes overlapping source-path refresh requests', async () => { @@ -73,7 +72,6 @@ suite('testController - loadChildren', () => { } }); const watcherPatternsStub = sinon.stub(testSourceProvider, 'getTestSourcePattern').resolves([]); - const additionalPathsStub = sinon.stub(testSourceProvider, 'getAdditionalTestSourcePaths').resolves([]); const firstRefresh: Promise = refreshTestSourcePaths(); await firstRefreshStarted; @@ -84,48 +82,46 @@ suite('testController - loadChildren', () => { assert.strictEqual(clearPathsStub.callCount, 2); assert.strictEqual(refreshExplorerStub.callCount, 2); assert.strictEqual(watcherPatternsStub.callCount, 2); - assert.strictEqual(additionalPathsStub.callCount, 2); }); - test('scans Java files found under configured additional source roots', async () => { - const javaFile: Uri = Uri.file('/workspace/additional/src/MainSourceTest.java'); - const refreshExplorerStub = sinon.stub(testExplorerCommands, 'refreshExplorer').resolves(); - const watcherPatternsStub = sinon.stub(testSourceProvider, 'getTestSourcePattern').resolves([]); - const additionalPathsStub = sinon.stub(testSourceProvider, 'getAdditionalTestSourcePaths') - .resolves(['/workspace/additional/src']); - const findFilesStub = sinon.stub(workspace, 'findFiles').resolves([javaFile]); - const findTestsStub = sinon.stub(controllerUtils, 'findTestTypesAndMethods').resolves([]); + 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 refreshTestSourcePaths(); + await loadChildren(project); - assert.ok(refreshExplorerStub.calledOnce); - assert.ok(watcherPatternsStub.calledOnce); - assert.ok(additionalPathsStub.calledOnce); - assert.ok(findFilesStub.calledOnce); - sinon.assert.calledOnceWithExactly(findTestsStub, javaFile.toString()); + sinon.assert.calledOnceWithExactly(findPackagesStub, 'project-handler', undefined, [additionalSourcePath]); }); - test('bounds concurrent additional-source discovery and preserves result order', async () => { - let activeRequests: number = 0; - let maximumActiveRequests: number = 0; - const findTestsStub = sinon.stub(controllerUtils, 'findTestTypesAndMethods').callsFake(async () => { - activeRequests++; - maximumActiveRequests = Math.max(maximumActiveRequests, activeRequests); - await new Promise((resolve) => setTimeout(resolve, 5)); - activeRequests--; - return []; - }); - const javaFiles: Uri[] = []; - for (let index: number = 0; index < 10; index++) { - javaFiles.push(Uri.file(`/workspace/src/Test${index}.java`)); - } + 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([]); - const discoveries = await discoverAdditionalTestTypes(javaFiles); + await loadChildren(project, undefined, true); - assert.strictEqual(findTestsStub.callCount, javaFiles.length); - assert.strictEqual(maximumActiveRequests, 4); - assert.deepStrictEqual(discoveries.map((discovery) => discovery.uri.toString()), - javaFiles.map((javaFile: Uri) => javaFile.toString())); + assert.strictEqual(project.children.size, 0); + sinon.assert.calledOnceWithExactly(findPackagesStub, 'project-handler', undefined, []); }); test('should reuse resolved project children until a forced refresh', async () => { @@ -427,4 +423,39 @@ 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(() => { + signalSourcePathsRequested(); + return sourcePaths; + }); + const findPackagesStub = sinon.stub(controllerUtils, 'findTestPackagesAndTypes').resolves([]); + + const resolution: Promise = loadChildren(project, cancellation.token); + await sourcePathsRequested; + cancellation.cancel(); + releaseSourcePaths(['/workspace/module/src/main/java']); + await resolution; + + 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 index 057cb0037..8ce0f7c28 100644 --- a/test/suite/testSourceProvider.test.ts +++ b/test/suite/testSourceProvider.test.ts @@ -8,11 +8,12 @@ import * as path from 'path'; import * as sinon from 'sinon'; import { 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'), []), []); + assert.deepStrictEqual(await resolveAdditionalTestSourcePaths(path.resolve('workspace'), [], []), []); }); test('resolves multiple relative paths against the workspace folder', async () => { @@ -20,7 +21,8 @@ suite('testSourceProvider', () => { 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); + 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 { @@ -37,7 +39,7 @@ suite('testSourceProvider', () => { relativePath, relativePath.replace(/\\/g, '/'), path.resolve(workspacePath, relativePath), - ]); + ], [path.resolve(workspacePath, relativePath)]); assert.deepStrictEqual(sourcePaths, [path.resolve(workspacePath, relativePath)]); } finally { @@ -65,7 +67,7 @@ suite('testSourceProvider', () => { const paths: string[] = await resolveAdditionalTestSourcePaths(workspacePath, [ 'modules/*/src/main/java', 'modules/app/src/main/java', - ]); + ], sourceRoots); assert.deepStrictEqual(paths.sort(), sourceRoots.sort()); } finally { @@ -92,6 +94,7 @@ suite('testSourceProvider', () => { 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); @@ -108,6 +111,105 @@ suite('testSourceProvider', () => { } }); + 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('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'); @@ -116,7 +218,7 @@ suite('testSourceProvider', () => { try { const paths: string[] = await resolveAdditionalTestSourcePaths(workspacePath, [ 'modules\\*\\src\\main\\java', - ]); + ], [sourceRoot]); assert.deepStrictEqual(paths, [sourceRoot]); } finally { @@ -129,7 +231,7 @@ suite('testSourceProvider', () => { try { const paths: string[] = await resolveAdditionalTestSourcePaths(workspacePath, [ 'modules/*/src/test/java', - ]); + ], []); assert.deepStrictEqual(paths, []); } finally { @@ -137,10 +239,11 @@ suite('testSourceProvider', () => { } }); - test('ignores literal paths that do not exist as directories', async () => { + 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']); + const paths: string[] = await resolveAdditionalTestSourcePaths(workspacePath, + ['modules/missing/src/main/java'], []); assert.deepStrictEqual(paths, []); } finally { @@ -149,6 +252,6 @@ suite('testSourceProvider', () => { }); test('ignores blank paths', async () => { - assert.deepStrictEqual(await resolveAdditionalTestSourcePaths(path.resolve('workspace'), [' ', '\t']), []); + assert.deepStrictEqual(await resolveAdditionalTestSourcePaths(path.resolve('workspace'), [' ', '\t'], []), []); }); }); From 36a2ce62a0270b4f06acb6df9098eacd0a68f6da Mon Sep 17 00:00:00 2001 From: Vinay Kumar Maheshwaram <54681400+sudovinay01@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:39:49 +0530 Subject: [PATCH 7/8] Harden test source path resolution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/controller/testController.ts | 37 +++++++++++- src/provider/testSourceProvider.ts | 18 ++++-- .../suite/testController.loadChildren.test.ts | 2 +- test/suite/testSourceProvider.test.ts | 58 +++++++++++++++++++ 4 files changed, 107 insertions(+), 8 deletions(-) diff --git a/src/controller/testController.ts b/src/controller/testController.ts index 9235488f6..bc21d4630 100644 --- a/src/controller/testController.ts +++ b/src/controller/testController.ts @@ -131,9 +131,21 @@ async function resolveTestItemChildren(item: TestItem, data: ITestItemData, reso const workspaceFolder: WorkspaceFolder | undefined = item.uri ? workspace.getWorkspaceFolder(item.uri) : undefined; - const additionalSourcePaths: string[] = workspaceFolder - ? await testSourceProvider.getAdditionalTestSourcePaths(workspaceFolder) - : []; + let additionalSourcePaths: string[] = []; + if (workspaceFolder) { + const sourcePathsPromise: Promise = + testSourceProvider.getAdditionalTestSourcePaths(workspaceFolder); + 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; } @@ -163,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) { diff --git a/src/provider/testSourceProvider.ts b/src/provider/testSourceProvider.ts index ca00eb8c3..83cf44931 100644 --- a/src/provider/testSourceProvider.ts +++ b/src/provider/testSourceProvider.ts @@ -9,7 +9,7 @@ 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 { @@ -46,6 +46,11 @@ class TestSourcePathProvider { sourcePaths.map((sourcePath: ITestSourcePath) => sourcePath.testSourcePath))) : Promise.resolve([]); this.additionalTestSourceMapping.set(workspaceKey, resolvedPaths); + resolvedPaths.catch(() => { + if (this.additionalTestSourceMapping.get(workspaceKey) === resolvedPaths) { + this.additionalTestSourceMapping.delete(workspaceKey); + } + }); } return resolvedPaths; } @@ -78,13 +83,18 @@ class TestSourcePathProvider { private async getTestPaths(workspaceFolder: WorkspaceFolder): Promise { const workspaceKey: string = workspaceFolder.uri.toString(); - let testPaths: ITestSourcePath[] | undefined = this.testSourceMapping.get(workspaceKey); + let testPaths: Promise | undefined = this.testSourceMapping.get(workspaceKey); if (!testPaths) { - testPaths = await getTestSourcePaths([workspaceFolder.uri.toString()]); + testPaths = getTestSourcePaths([workspaceFolder.uri.toString()]); this.testSourceMapping.set(workspaceKey, testPaths); + testPaths.catch(() => { + if (this.testSourceMapping.get(workspaceKey) === testPaths) { + this.testSourceMapping.delete(workspaceKey); + } + }); } - return mergeTestSourcePaths(testPaths, await this.getAdditionalTestSourcePaths(workspaceFolder)); + return mergeTestSourcePaths(await testPaths, await this.getAdditionalTestSourcePaths(workspaceFolder)); } } diff --git a/test/suite/testController.loadChildren.test.ts b/test/suite/testController.loadChildren.test.ts index 54c135ea2..eb852ea24 100644 --- a/test/suite/testController.loadChildren.test.ts +++ b/test/suite/testController.loadChildren.test.ts @@ -451,8 +451,8 @@ suite('testController - loadChildren', () => { const resolution: Promise = loadChildren(project, cancellation.token); await sourcePathsRequested; cancellation.cancel(); - releaseSourcePaths(['/workspace/module/src/main/java']); await resolution; + releaseSourcePaths(['/workspace/module/src/main/java']); assert.ok(findPackagesStub.notCalled); assert.strictEqual(project.canResolveChildren, true); diff --git a/test/suite/testSourceProvider.test.ts b/test/suite/testSourceProvider.test.ts index 8ce0f7c28..26d4e97a3 100644 --- a/test/suite/testSourceProvider.test.ts +++ b/test/suite/testSourceProvider.test.ts @@ -140,6 +140,64 @@ suite('testSourceProvider', () => { } }); + 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('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[] = [ From 99060a7e47e72b5df8ba53ce6a4fbc3dc71b0398 Mon Sep 17 00:00:00 2001 From: Vinay Kumar Maheshwaram <54681400+sudovinay01@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:43:54 +0530 Subject: [PATCH 8/8] Propagate cancellation to source path lookup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/controller/testController.ts | 2 +- src/provider/testSourceProvider.ts | 32 ++++++++++++++----- .../suite/testController.loadChildren.test.ts | 4 ++- test/suite/testSourceProvider.test.ts | 32 ++++++++++++++++++- 4 files changed, 59 insertions(+), 11 deletions(-) diff --git a/src/controller/testController.ts b/src/controller/testController.ts index bc21d4630..029d13c93 100644 --- a/src/controller/testController.ts +++ b/src/controller/testController.ts @@ -134,7 +134,7 @@ async function resolveTestItemChildren(item: TestItem, data: ITestItemData, reso let additionalSourcePaths: string[] = []; if (workspaceFolder) { const sourcePathsPromise: Promise = - testSourceProvider.getAdditionalTestSourcePaths(workspaceFolder); + testSourceProvider.getAdditionalTestSourcePaths(workspaceFolder, token); if (token) { const resolvedSourcePaths: string[] | undefined = await awaitWithCancellation(sourcePathsPromise, token); diff --git a/src/provider/testSourceProvider.ts b/src/provider/testSourceProvider.ts index 83cf44931..8b9be4446 100644 --- a/src/provider/testSourceProvider.ts +++ b/src/provider/testSourceProvider.ts @@ -4,7 +4,7 @@ import * as path from 'path'; import * as minimatch from 'minimatch'; import { hasMagic } from 'glob'; -import { OutputChannel, RelativePattern, Uri, window, workspace, WorkspaceFolder } from 'vscode'; +import { CancellationToken, OutputChannel, RelativePattern, Uri, window, workspace, WorkspaceFolder } from 'vscode'; import { JavaTestRunnerDelegateCommands } from '../constants'; import { executeJavaLanguageServerCommand } from '../utils/commandUtils'; @@ -34,17 +34,29 @@ class TestSourcePathProvider { .map((s: ITestSourcePath) => s.testSourcePath); } - public getAdditionalTestSourcePaths(workspaceFolder: WorkspaceFolder): Promise { + 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 = configuredPaths.length > 0 - ? getSourcePaths([workspaceFolder.uri.toString()]).then((sourcePaths: ITestSourcePath[]) => - resolveAdditionalTestSourcePaths(workspaceFolder.uri.fsPath, configuredPaths, - sourcePaths.map((sourcePath: ITestSourcePath) => sourcePath.testSourcePath))) - : Promise.resolve([]); + 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) { @@ -205,7 +217,11 @@ async function getTestSourcePaths(uri: string[]): Promise { JavaTestRunnerDelegateCommands.GET_TEST_SOURCE_PATH, uri) || []; } -async function getSourcePaths(uri: string[]): Promise { +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) || []; } diff --git a/test/suite/testController.loadChildren.test.ts b/test/suite/testController.loadChildren.test.ts index eb852ea24..828826347 100644 --- a/test/suite/testController.loadChildren.test.ts +++ b/test/suite/testController.loadChildren.test.ts @@ -442,7 +442,9 @@ suite('testController - loadChildren', () => { }); const cancellation: CancellationTokenSource = new CancellationTokenSource(); sinon.stub(workspace, 'getWorkspaceFolder').returns(workspaceFolder); - sinon.stub(testSourceProvider, 'getAdditionalTestSourcePaths').callsFake(() => { + sinon.stub(testSourceProvider, 'getAdditionalTestSourcePaths').callsFake((folder, token) => { + assert.strictEqual(folder, workspaceFolder); + assert.strictEqual(token, cancellation.token); signalSourcePathsRequested(); return sourcePaths; }); diff --git a/test/suite/testSourceProvider.test.ts b/test/suite/testSourceProvider.test.ts index 26d4e97a3..b2f228bc3 100644 --- a/test/suite/testSourceProvider.test.ts +++ b/test/suite/testSourceProvider.test.ts @@ -6,7 +6,7 @@ import * as fse from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; -import { RelativePattern, Uri, WorkspaceConfiguration, WorkspaceFolder, workspace } from 'vscode'; +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'; @@ -169,6 +169,36 @@ suite('testSourceProvider', () => { } }); + 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');