From ee43595770b4dd21380c7ed2443c0fe9e69c6c90 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Wed, 6 May 2026 14:14:23 +0800 Subject: [PATCH 01/10] Run multiple selected test methods in a single JVM (#1836) When several test methods of the same class are selected in the Test Explorer, vscode-java-test currently launches one JVM per method, which makes Spring-style tests (where each JVM rebuilds the ApplicationContext in @BeforeAll) extremely slow. Group methods of the same class into one launch and rely on the new 'Class:method' line format supported by the bundled Eclipse JDT JUnit runtime so that all selected methods are discovered inside a single JVM, sharing per-class @BeforeAll/@AfterAll lifecycle and any cached fixture (e.g. Spring ApplicationContext). Methods restricted to a single invocation (uniqueId) keep their own launch, since the underlying protocol carries at most one uniqueId per JVM. --- .../JUnitLaunchConfigurationDelegate.java | 107 ++++++++++++------ src/controller/testController.ts | 15 ++- 2 files changed, 84 insertions(+), 38 deletions(-) diff --git a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java index ddbfb262..33a7498a 100644 --- a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java +++ b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java @@ -144,50 +144,65 @@ private void addTestItemArgs(List arguments) throws CoreException { arguments.add("-testNameFile"); arguments.add(fileName); } else if (this.args.testLevel == TestLevel.METHOD) { - arguments.add("-test"); - final IMethod method = (IMethod) JavaCore.create(this.args.testNames[0]); - String testName = method.getElementName(); - if ((this.args.testKind == TestKind.JUnit5 || this.args.testKind == TestKind.JUnit6) && - method.getParameters().length > 0) { - final ICompilationUnit unit = method.getCompilationUnit(); - if (unit == null) { - throw new CoreException(new Status(IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR, - "Cannot get compilation unit of method" + method.getElementName(), null)); //$NON-NLS-1$ - } - final CompilationUnit root = (CompilationUnit) TestSearchUtils.parseToAst(unit, - false /*fromCache*/, new NullProgressMonitor()); - final MethodDeclaration methodDeclaration = ASTNodeSearchUtil.getMethodDeclarationNode(method, root); - if (methodDeclaration == null) { - throw new CoreException(new Status(IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR, - "Cannot get method declaration of method" + method.getElementName(), null)); //$NON-NLS-1$ + if (this.args.testNames.length > 1) { + // Multi-method launch: hand the full selection to RemoteTestRunner via + // -testNameFile using the new "Class:method" line format. The runner + // will then load every selected method inside a single test JVM, so + // per-class @BeforeAll/@AfterAll and any cached Spring + // ApplicationContext are reused across the selection. + final String fileName = createMethodTestNamesFile(this.args.testNames); + arguments.add("-testNameFile"); + arguments.add(fileName); + } else { + arguments.add("-test"); + arguments.add(resolveMethodTestName(this.args.testNames[0])); + + if (StringUtils.isNotBlank(this.args.uniqueId)) { + arguments.add("-uniqueId"); + arguments.add(this.args.uniqueId); } + } + } + } - final List parameters = new LinkedList<>(); - for (final Object obj : methodDeclaration.parameters()) { - if (obj instanceof SingleVariableDeclaration) { - final ITypeBinding paramTypeBinding = ((SingleVariableDeclaration) obj) - .getType().resolveBinding(); - if (paramTypeBinding == null) { - throw new CoreException(new Status(IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR, - "Cannot set set argument for method" + methodDeclaration.toString(), null)); - } else if (paramTypeBinding.isPrimitive()) { - parameters.add(paramTypeBinding.getQualifiedName()); - } else { - parameters.add(paramTypeBinding.getBinaryName()); - } + private String resolveMethodTestName(String handleId) throws CoreException { + final IMethod method = (IMethod) JavaCore.create(handleId); + String testName = method.getElementName(); + if ((this.args.testKind == TestKind.JUnit5 || this.args.testKind == TestKind.JUnit6) && + method.getParameters().length > 0) { + final ICompilationUnit unit = method.getCompilationUnit(); + if (unit == null) { + throw new CoreException(new Status(IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR, + "Cannot get compilation unit of method" + method.getElementName(), null)); //$NON-NLS-1$ + } + final CompilationUnit root = (CompilationUnit) TestSearchUtils.parseToAst(unit, + false /*fromCache*/, new NullProgressMonitor()); + final MethodDeclaration methodDeclaration = ASTNodeSearchUtil.getMethodDeclarationNode(method, root); + if (methodDeclaration == null) { + throw new CoreException(new Status(IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR, + "Cannot get method declaration of method" + method.getElementName(), null)); //$NON-NLS-1$ + } + + final List parameters = new LinkedList<>(); + for (final Object obj : methodDeclaration.parameters()) { + if (obj instanceof SingleVariableDeclaration) { + final ITypeBinding paramTypeBinding = ((SingleVariableDeclaration) obj) + .getType().resolveBinding(); + if (paramTypeBinding == null) { + throw new CoreException(new Status(IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR, + "Cannot set set argument for method" + methodDeclaration.toString(), null)); + } else if (paramTypeBinding.isPrimitive()) { + parameters.add(paramTypeBinding.getQualifiedName()); + } else { + parameters.add(paramTypeBinding.getBinaryName()); } } - if (parameters.size() > 0) { - testName += "(" + String.join(",", parameters) + ")"; - } } - arguments.add(method.getDeclaringType().getFullyQualifiedName() + ':' + testName); - - if (StringUtils.isNotBlank(this.args.uniqueId)) { - arguments.add("-uniqueId"); - arguments.add(this.args.uniqueId); + if (parameters.size() > 0) { + testName += "(" + String.join(",", parameters) + ")"; } } + return method.getDeclaringType().getFullyQualifiedName() + ':' + testName; } private String createTestNamesFile(String[] testNames) throws CoreException { @@ -207,4 +222,22 @@ private String createTestNamesFile(String[] testNames) throws CoreException { IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR, "", e)); //$NON-NLS-1$ } } + + private String createMethodTestNamesFile(String[] testNames) throws CoreException { + try { + final File file = File.createTempFile("testNames", ".txt"); //$NON-NLS-1$ //$NON-NLS-2$ + file.deleteOnExit(); + try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter( + new FileOutputStream(file), StandardCharsets.UTF_8));) { + for (final String handleId : testNames) { + bw.write(resolveMethodTestName(handleId)); + bw.newLine(); + } + } + return file.getAbsolutePath(); + } catch (IOException e) { + throw new CoreException(new Status( + IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR, "", e)); //$NON-NLS-1$ + } + } } diff --git a/src/controller/testController.ts b/src/controller/testController.ts index cdacd518..59b8c189 100644 --- a/src/controller/testController.ts +++ b/src/controller/testController.ts @@ -650,8 +650,21 @@ function mergeTestMethods(testItems: TestItem[]): TestItem[][] { && !([...methods].some((m: TestItem) => dataCache.get(m)?.uniqueId))) { classMapping.set(clazz.id, clazz); } else { + // Methods restricted to a single invocation (uniqueId) must still run in their + // own launch since the underlying protocol carries at most one uniqueId per JVM. + // Every other method of the same class can share one launch so that + // @BeforeAll / @AfterAll and any cached fixture (e.g. Spring ApplicationContext) + // are reused across the selection. See issue #1836. + const groupable: TestItem[] = []; for (const method of methods.values()) { - testMethods.push([method]); + if (dataCache.get(method)?.uniqueId) { + testMethods.push([method]); + } else { + groupable.push(method); + } + } + if (groupable.length > 0) { + testMethods.push(groupable); } } } From 27a4ae543fc452bf8841ca946ba86a394c3e3d2c Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Thu, 7 May 2026 11:24:20 +0800 Subject: [PATCH 02/10] perf: add guard to multi method run in single jvm --- .../JUnitLaunchConfigurationDelegate.java | 14 +++++++ .../plugin/launchers/JUnitLaunchUtils.java | 41 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java index 33a7498a..51550c45 100644 --- a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java +++ b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java @@ -145,6 +145,20 @@ private void addTestItemArgs(List arguments) throws CoreException { arguments.add(fileName); } else if (this.args.testLevel == TestLevel.METHOD) { if (this.args.testNames.length > 1) { + if (!JUnitLaunchUtils.supportsMultiMethodLaunch()) { + // The Class:method protocol is parsed by RemoteTestRunner inside + // org.eclipse.jdt.junit.runtime, which ships with the Eclipse Java + // Language Server (Language Support for Java(TM) by Red Hat). When + // that bundle predates eclipse.jdt.ui#2975, batching multiple + // methods into a single JVM would surface as a ClassNotFoundException + // at test time. Fail fast here with an actionable message instead. + throw new CoreException(new Status(IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR, + "Running multiple test methods together in a single JVM requires a newer " + + "Eclipse Java Language Server (org.eclipse.jdt.junit.runtime). " + + "Please update the 'Language Support for Java(TM) by Red Hat' " + + "extension and retry, or run the selected methods one at a time.", + null)); + } // Multi-method launch: hand the full selection to RemoteTestRunner via // -testNameFile using the new "Class:method" line format. The runner // will then load every selected method inside a single test JVM, so diff --git a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchUtils.java b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchUtils.java index a98dcc03..c3c5b975 100644 --- a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchUtils.java +++ b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchUtils.java @@ -22,6 +22,7 @@ import org.eclipse.core.runtime.CoreException; import org.eclipse.core.runtime.IPath; import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.Platform; import org.eclipse.debug.core.DebugPlugin; import org.eclipse.debug.core.ILaunchConfiguration; import org.eclipse.jdt.core.IJavaElement; @@ -34,6 +35,8 @@ import org.eclipse.jdt.launching.IRuntimeClasspathEntry; import org.eclipse.jdt.launching.JavaRuntime; import org.eclipse.jdt.ls.core.internal.ProjectUtils; +import org.osgi.framework.Bundle; +import org.osgi.framework.Version; import java.net.URISyntaxException; import java.util.ArrayList; @@ -51,8 +54,46 @@ public class JUnitLaunchUtils { private static final String JUNIT5_LOADER = "org.eclipse.jdt.junit.loader.junit5"; private static final String JUNIT4_LOADER = "org.eclipse.jdt.junit.loader.junit4"; + /** + * Bundle that hosts {@code RemoteTestRunner}, the consumer of the + * {@code -testNameFile} content. This jar is shipped by the Eclipse Java + * Language Server (i.e. the "Language Support for Java(TM) by Red Hat" + * extension), not by vscode-java-test itself. + */ + private static final String JUNIT_RUNTIME_BUNDLE = "org.eclipse.jdt.junit.runtime"; + + /** + * Minimum {@code org.eclipse.jdt.junit.runtime} version that recognises the + * {@code Class:method} multi-method launch protocol introduced by + * eclipse.jdt.ui#2975. + * + *

TODO: bump this placeholder to the first published bundle version once + * the upstream change is included in an Eclipse Platform release that ships + * with redhat.java. Until then the placeholder is permissive (every real + * bundle compares as supported), so that local development against a + * freshly-built JDT-LS continues to work. + */ + private static final Version MIN_JDT_JUNIT_RUNTIME_VERSION_FOR_MULTI_METHOD = + Version.parseVersion("0.0.0"); + private JUnitLaunchUtils() {} + /** + * @return {@code true} when the resolved {@code org.eclipse.jdt.junit.runtime} + * bundle is new enough to parse the {@code Class:method} multi-method launch + * protocol; {@code false} otherwise. When this returns {@code false}, callers + * must not batch multiple methods into a single JVM via the + * {@code -testNameFile} mechanism — the legacy per-method launch path should + * be used instead. + */ + public static boolean supportsMultiMethodLaunch() { + final Bundle bundle = Platform.getBundle(JUNIT_RUNTIME_BUNDLE); + if (bundle == null) { + return false; + } + return bundle.getVersion().compareTo(MIN_JDT_JUNIT_RUNTIME_VERSION_FOR_MULTI_METHOD) >= 0; + } + /** * Resolve the arguments to launch the Eclipse test runner * @param arguments From 495d197d6362605e6b333163ef5c3998a7763339 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Thu, 7 May 2026 12:01:03 +0800 Subject: [PATCH 03/10] fix --- src/controller/testController.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/controller/testController.ts b/src/controller/testController.ts index 59b8c189..d1904eeb 100644 --- a/src/controller/testController.ts +++ b/src/controller/testController.ts @@ -260,6 +260,10 @@ export const runTests: (request: TestRunRequest, option: IRunOption) => any = in trackTestFrameworkVersion(testContext.kind, resolvedConfiguration.classPaths, resolvedConfiguration.modulePaths); await runner.run(resolvedConfiguration, token, option.progressReporter); } catch (error) { + const message: TestMessage = new TestMessage(error.message || 'Failed to run tests.'); + for (const item of testContext.testItems) { + run.errored(item, message); + } window.showErrorMessage(error.message || 'Failed to run tests.'); option.progressReporter?.done(); } finally { From cccbe9bd6a821eb214012cae058f6f9f21cb6678 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Thu, 7 May 2026 16:56:14 +0800 Subject: [PATCH 04/10] Set min jdt.junit.runtime version to 3.8.100 for multi-method launch Upstream eclipse.jdt.ui#2975 ships the new multi-method dispatch in org.eclipse.jdt.junit.runtime 3.8.100. Replace the placeholder threshold so the capability gate falls back to the legacy per-method launch path when run against older JDT-LS versions. --- .../java/test/plugin/launchers/JUnitLaunchUtils.java | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchUtils.java b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchUtils.java index c3c5b975..c19779f5 100644 --- a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchUtils.java +++ b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchUtils.java @@ -66,15 +66,9 @@ public class JUnitLaunchUtils { * Minimum {@code org.eclipse.jdt.junit.runtime} version that recognises the * {@code Class:method} multi-method launch protocol introduced by * eclipse.jdt.ui#2975. - * - *

TODO: bump this placeholder to the first published bundle version once - * the upstream change is included in an Eclipse Platform release that ships - * with redhat.java. Until then the placeholder is permissive (every real - * bundle compares as supported), so that local development against a - * freshly-built JDT-LS continues to work. */ private static final Version MIN_JDT_JUNIT_RUNTIME_VERSION_FOR_MULTI_METHOD = - Version.parseVersion("0.0.0"); + Version.parseVersion("3.8.100"); private JUnitLaunchUtils() {} From 5d7273f71dc52da306a983ef58c2f54680e1392a Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Thu, 7 May 2026 20:21:25 +0800 Subject: [PATCH 05/10] feat: patch multi method to java osgi --- .../JUnitLaunchConfigurationDelegate.java | 15 ++-- .../plugin/launchers/JUnitLaunchUtils.java | 13 ++++ src/constants.ts | 16 ++++ src/controller/testController.ts | 78 +++++++++++++++++-- 4 files changed, 112 insertions(+), 10 deletions(-) diff --git a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java index 51550c45..2a790d11 100644 --- a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java +++ b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java @@ -151,12 +151,17 @@ private void addTestItemArgs(List arguments) throws CoreException { // Language Server (Language Support for Java(TM) by Red Hat). When // that bundle predates eclipse.jdt.ui#2975, batching multiple // methods into a single JVM would surface as a ClassNotFoundException - // at test time. Fail fast here with an actionable message instead. + // at test time. Fail fast here with a marker the TypeScript side + // recognises so it can transparently fall back to launching each + // method in its own JVM (the legacy per-method path). The actionable + // text after the marker is preserved as a defensive fallback in case + // the fallback path itself also fails for an unrelated reason. throw new CoreException(new Status(IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR, - "Running multiple test methods together in a single JVM requires a newer " + - "Eclipse Java Language Server (org.eclipse.jdt.junit.runtime). " + - "Please update the 'Language Support for Java(TM) by Red Hat' " + - "extension and retry, or run the selected methods one at a time.", + JUnitLaunchUtils.MULTI_METHOD_LAUNCH_UNSUPPORTED_PREFIX + + "Running multiple test methods together in a single JVM requires a newer " + + "Eclipse Java Language Server (org.eclipse.jdt.junit.runtime). " + + "Please update the 'Language Support for Java(TM) by Red Hat' " + + "extension and retry, or run the selected methods one at a time.", null)); } // Multi-method launch: hand the full selection to RemoteTestRunner via diff --git a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchUtils.java b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchUtils.java index c19779f5..05109851 100644 --- a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchUtils.java +++ b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchUtils.java @@ -62,6 +62,19 @@ public class JUnitLaunchUtils { */ private static final String JUNIT_RUNTIME_BUNDLE = "org.eclipse.jdt.junit.runtime"; + /** + * Stable prefix prepended to the {@link CoreException} thrown when the + * resolved {@code org.eclipse.jdt.junit.runtime} bundle is too old to + * understand the {@code Class:method} multi-method launch protocol. The + * TypeScript side detects this prefix and silently falls back to launching + * every selected method in its own JVM (the legacy per-method path), + * keeping the user experience identical to the pre-batching behaviour on + * older Eclipse Java Language Server releases. Do NOT change the value + * without updating the corresponding constant on the client side. + */ + public static final String MULTI_METHOD_LAUNCH_UNSUPPORTED_PREFIX = + "MULTI_METHOD_LAUNCH_UNSUPPORTED: "; + /** * Minimum {@code org.eclipse.jdt.junit.runtime} version that recognises the * {@code Class:method} multi-method launch protocol introduced by diff --git a/src/constants.ts b/src/constants.ts index b23bf197..c0c92857 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -66,6 +66,22 @@ export namespace Context { export const ACTIVATION_CONTEXT_KEY: string = 'java:testRunnerActivated'; } +export namespace JUnitLaunchProtocol { + /** + * Stable prefix prepended to the launch-resolution error thrown by the + * Java plugin when the bundled {@code org.eclipse.jdt.junit.runtime} + * predates the {@code Class:method} multi-method launch protocol + * introduced by eclipse.jdt.ui#2975. The TypeScript runner detects this + * prefix and silently re-launches every selected method in its own JVM + * (the legacy per-method path), keeping the user experience identical to + * the pre-batching behaviour on older Eclipse Java Language Server + * releases. Keep in sync with + * {@code JUnitLaunchUtils.MULTI_METHOD_LAUNCH_UNSUPPORTED_PREFIX} on the + * Java side. + */ + export const MULTI_METHOD_LAUNCH_UNSUPPORTED_PREFIX: string = 'MULTI_METHOD_LAUNCH_UNSUPPORTED: '; +} + /** * The different part keys returned by the JUnit test runner, * which are used to identify the test cases. diff --git a/src/controller/testController.ts b/src/controller/testController.ts index d1904eeb..3cec296a 100644 --- a/src/controller/testController.ts +++ b/src/controller/testController.ts @@ -12,6 +12,7 @@ import { testSourceProvider } from '../provider/testSourceProvider'; import { BaseRunner } from '../runners/baseRunner/BaseRunner'; import { JUnitRunner } from '../runners/junitRunner/JunitRunner'; import { TestNGRunner } from '../runners/testngRunner/TestNGRunner'; +import { JUnitLaunchProtocol } from '../constants'; import { IJavaTestItem } from '../types'; import { loadRunConfig } from '../utils/configUtils'; import { resolveLaunchConfigurationForRunner } from '../utils/launchUtils'; @@ -260,12 +261,31 @@ export const runTests: (request: TestRunRequest, option: IRunOption) => any = in trackTestFrameworkVersion(testContext.kind, resolvedConfiguration.classPaths, resolvedConfiguration.modulePaths); await runner.run(resolvedConfiguration, token, option.progressReporter); } catch (error) { - const message: TestMessage = new TestMessage(error.message || 'Failed to run tests.'); - for (const item of testContext.testItems) { - run.errored(item, message); + const message: string = error?.message || 'Failed to run tests.'; + if (typeof error?.message === 'string' + && error.message.startsWith(JUnitLaunchProtocol.MULTI_METHOD_LAUNCH_UNSUPPORTED_PREFIX) + && testContext.testItems.length > 1) { + // Silent fallback for older Eclipse Java Language Server + // releases (predating eclipse.jdt.ui#2975): re-launch every + // selected method in its own JVM. From here on the cancel + // handler must defer to the per-item debug sessions, just + // as it would for a normal multi-group run. + delegatedToDebugger = true; + const itemsToRetry: TestItem[] = [...testContext.testItems]; + for (const item of itemsToRetry) { + if (token.isCancellationRequested) { + break; + } + await runItemInIsolatedLaunch(item, testContext, option, run, token); + } + } else { + const testMessage: TestMessage = new TestMessage(message); + for (const item of testContext.testItems) { + run.errored(item, testMessage); + } + window.showErrorMessage(message); + option.progressReporter?.done(); } - window.showErrorMessage(error.message || 'Failed to run tests.'); - option.progressReporter?.done(); } finally { await runner.tearDown(); } @@ -724,6 +744,54 @@ function getRunnerByContext(testContext: IRunTestContext): BaseRunner | undefine } } +/** + * Run a single test item through its own setup → resolve → run → tearDown + * cycle. Used as a silent fallback when the bundled JDT-LS does not yet + * understand the {@code Class:method} multi-method launch protocol + * (eclipse.jdt.ui#2975) — every selected method is re-launched in its own JVM, + * matching the pre-batching behaviour. Errors during the fallback are + * surfaced per-item so unrelated failures (e.g. compilation errors) are still + * reported to the user; the multi-method marker itself is filtered out so the + * scary "requires a newer..." text never reaches the popup. + */ +async function runItemInIsolatedLaunch( + item: TestItem, + parentContext: IRunTestContext, + option: IRunOption, + run: TestRun, + token: CancellationToken, +): Promise { + const singleContext: IRunTestContext = { + ...parentContext, + testItems: [item], + }; + const runner: BaseRunner | undefined = getRunnerByContext(singleContext); + if (!runner) { + run.errored(item, new TestMessage(`Failed to get suitable runner for the test kind: ${singleContext.kind}.`)); + return; + } + try { + await runner.setup(); + const resolvedConfiguration: DebugConfiguration = mergeConfigurations(option.launchConfiguration, singleContext.testConfig) + ?? await resolveLaunchConfigurationForRunner(runner, singleContext, singleContext.testConfig); + resolvedConfiguration.__progressId = option.progressReporter?.getId(); + trackTestFrameworkVersion(singleContext.kind, resolvedConfiguration.classPaths, resolvedConfiguration.modulePaths); + await runner.run(resolvedConfiguration, token, option.progressReporter); + } catch (error) { + const rawMessage: string = error?.message || 'Failed to run tests.'; + // Strip the internal marker if it ever bubbles up here (e.g. the user + // somehow ends up with a single-method batch that still hits the + // capability gate) so the popup stays user-readable. + const message: string = rawMessage.startsWith(JUnitLaunchProtocol.MULTI_METHOD_LAUNCH_UNSUPPORTED_PREFIX) + ? rawMessage.substring(JUnitLaunchProtocol.MULTI_METHOD_LAUNCH_UNSUPPORTED_PREFIX.length) + : rawMessage; + run.errored(item, new TestMessage(message)); + window.showErrorMessage(message); + } finally { + await runner.tearDown(); + } +} + function trackTestFrameworkVersion(testKind: TestKind, classpaths: string[], modulepaths: string[]) { let artifactPattern: RegExp; switch (testKind) { From 1ef6de8e488273b28789d6179d12a4e08039186a Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Thu, 7 May 2026 20:43:55 +0800 Subject: [PATCH 06/10] feat: add --- src/controller/testController.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/controller/testController.ts b/src/controller/testController.ts index 3cec296a..9741ab20 100644 --- a/src/controller/testController.ts +++ b/src/controller/testController.ts @@ -276,6 +276,14 @@ export const runTests: (request: TestRunRequest, option: IRunOption) => any = in if (token.isCancellationRequested) { break; } + // Each per-item launch hands its progress to the debugger + // via __progressId, and the debugger calls done() when the + // session ends. The next iteration must therefore obtain a + // fresh progress reporter — mirroring the same isCancelled + // reset that the outer per-kind loop performs at line ~240. + if (option.progressReporter?.isCancelled()) { + option.progressReporter = progressProvider?.createProgressReporter(option.isDebug ? 'Debug Tests' : 'Run Tests'); + } await runItemInIsolatedLaunch(item, testContext, option, run, token); } } else { From 34fdf4cae0469e94b52aaf9f4421b08cd6df9e7a Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Thu, 7 May 2026 21:22:23 +0800 Subject: [PATCH 07/10] test: add test case --- src/controller/testController.ts | 2 +- .../testController.mergeTestMethods.test.ts | 170 ++++++++++++++++++ 2 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 test/suite/testController.mergeTestMethods.test.ts diff --git a/src/controller/testController.ts b/src/controller/testController.ts index 9741ab20..3753ff48 100644 --- a/src/controller/testController.ts +++ b/src/controller/testController.ts @@ -631,7 +631,7 @@ function removeNonRerunTestInvocations(testItems: TestItem[]): void { * Because the current test runner cannot run class and methods for the same time, * in the returned array, all the classes are in one group and each method is a group. */ -function mergeTestMethods(testItems: TestItem[]): TestItem[][] { +export function mergeTestMethods(testItems: TestItem[]): TestItem[][] { // export for unit test if (testItems.length <= 1) { return [testItems]; } diff --git a/test/suite/testController.mergeTestMethods.test.ts b/test/suite/testController.mergeTestMethods.test.ts new file mode 100644 index 00000000..6fe00ddb --- /dev/null +++ b/test/suite/testController.mergeTestMethods.test.ts @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as assert from 'assert'; +import { TestController, TestItem, tests } from 'vscode'; +import { mergeTestMethods } from '../../src/controller/testController'; +import { dataCache } from '../../src/controller/testItemDataCache'; +import { setupTestEnv } from './utils'; +import { TestKind, TestLevel } from '../../src/java-test-runner.api'; + +function generateTestItem(testController: TestController, id: string, testLevel: TestLevel, uniqueId?: string): TestItem { + const testItem = testController.createTestItem(id, id + '_label'); + dataCache.set(testItem, { + jdtHandler: id + '_jdtHandler', + fullName: id + '_fullName', + projectName: id + '_projectName', + testLevel, + testKind: TestKind.JUnit5, + uniqueId, + }); + return testItem; +} + +suite('testController - mergeTestMethods', () => { + + let testController: TestController; + + suiteSetup(async function () { + await setupTestEnv(); + }); + + setup(() => { + testController = tests.createTestController('mergeTestMethodsTestController', 'mergeTestMethodsTestController'); + }); + + teardown(() => { + testController.dispose(); + }); + + test('should return the input untouched when empty', () => { + assert.deepStrictEqual(mergeTestMethods([]), [[]]); + }); + + test('should return the input untouched when a single item is selected', () => { + const method = generateTestItem(testController, 'id_1', TestLevel.Method); + assert.deepStrictEqual(mergeTestMethods([method]), [[method]]); + }); + + test('should batch methods of the same class into one launch group', () => { + const clazz = generateTestItem(testController, 'class_1', TestLevel.Class); + testController.items.add(clazz); + const method1 = generateTestItem(testController, 'method_1', TestLevel.Method); + const method2 = generateTestItem(testController, 'method_2', TestLevel.Method); + const method3 = generateTestItem(testController, 'method_3', TestLevel.Method); + clazz.children.add(method1); + clazz.children.add(method2); + clazz.children.add(method3); + + // select only 2 of 3 methods - they must share one launch (the core PR #1862 change) + const result = mergeTestMethods([method1, method2]); + + assert.deepStrictEqual(result, [[], [method1, method2]]); + }); + + test('should upgrade to a class launch when all methods of the class are selected', () => { + const clazz = generateTestItem(testController, 'class_1', TestLevel.Class); + testController.items.add(clazz); + const method1 = generateTestItem(testController, 'method_1', TestLevel.Method); + const method2 = generateTestItem(testController, 'method_2', TestLevel.Method); + clazz.children.add(method1); + clazz.children.add(method2); + + const result = mergeTestMethods([method1, method2]); + + assert.deepStrictEqual(result, [[clazz]]); + }); + + test('should not upgrade to a class launch when any selected method is restricted to a single invocation', () => { + const clazz = generateTestItem(testController, 'class_1', TestLevel.Class); + testController.items.add(clazz); + const method1 = generateTestItem(testController, 'method_1', TestLevel.Method, 'unique_1'); + const method2 = generateTestItem(testController, 'method_2', TestLevel.Method); + clazz.children.add(method1); + clazz.children.add(method2); + + const result = mergeTestMethods([method1, method2]); + + // method1 is invocation-restricted -> isolated; method2 batched alone in its own group + assert.deepStrictEqual(result, [[], [method1], [method2]]); + }); + + test('should isolate uniqueId methods but still batch the remaining methods of the same class', () => { + const clazz = generateTestItem(testController, 'class_1', TestLevel.Class); + testController.items.add(clazz); + const method1 = generateTestItem(testController, 'method_1', TestLevel.Method, 'unique_1'); + const method2 = generateTestItem(testController, 'method_2', TestLevel.Method); + const method3 = generateTestItem(testController, 'method_3', TestLevel.Method); + const method4 = generateTestItem(testController, 'method_4', TestLevel.Method); + clazz.children.add(method1); + clazz.children.add(method2); + clazz.children.add(method3); + clazz.children.add(method4); + + const result = mergeTestMethods([method1, method2, method3]); + + assert.deepStrictEqual(result, [[], [method1], [method2, method3]]); + }); + + test('should keep methods of different parent classes in their own launch groups', () => { + const classA = generateTestItem(testController, 'class_A', TestLevel.Class); + const classB = generateTestItem(testController, 'class_B', TestLevel.Class); + testController.items.add(classA); + testController.items.add(classB); + const a1 = generateTestItem(testController, 'a_1', TestLevel.Method); + const a2 = generateTestItem(testController, 'a_2', TestLevel.Method); + const b1 = generateTestItem(testController, 'b_1', TestLevel.Method); + const b2 = generateTestItem(testController, 'b_2', TestLevel.Method); + classA.children.add(a1); + classA.children.add(a2); + // make sure classA still has unselected children so it's not upgraded + classA.children.add(generateTestItem(testController, 'a_3', TestLevel.Method)); + classB.children.add(b1); + classB.children.add(b2); + classB.children.add(generateTestItem(testController, 'b_3', TestLevel.Method)); + + const result = mergeTestMethods([a1, b1, a2, b2]); + + assert.deepStrictEqual(result, [[], [a1, a2], [b1, b2]]); + }); + + test('should drop methods whose parent class is also selected', () => { + const clazz = generateTestItem(testController, 'class_1', TestLevel.Class); + testController.items.add(clazz); + const method1 = generateTestItem(testController, 'method_1', TestLevel.Method); + const method2 = generateTestItem(testController, 'method_2', TestLevel.Method); + clazz.children.add(method1); + clazz.children.add(method2); + + const result = mergeTestMethods([clazz, method1]); + + assert.deepStrictEqual(result, [[clazz]]); + }); + + test('should keep multiple selected classes together in the first group', () => { + const classA = generateTestItem(testController, 'class_A', TestLevel.Class); + const classB = generateTestItem(testController, 'class_B', TestLevel.Class); + testController.items.add(classA); + testController.items.add(classB); + + const result = mergeTestMethods([classA, classB]); + + assert.deepStrictEqual(result, [[classA, classB]]); + }); + + test('should mix class-level and unrelated method-level selections correctly', () => { + const classA = generateTestItem(testController, 'class_A', TestLevel.Class); + const classB = generateTestItem(testController, 'class_B', TestLevel.Class); + testController.items.add(classA); + testController.items.add(classB); + const b1 = generateTestItem(testController, 'b_1', TestLevel.Method); + const b2 = generateTestItem(testController, 'b_2', TestLevel.Method); + classB.children.add(b1); + classB.children.add(b2); + classB.children.add(generateTestItem(testController, 'b_3', TestLevel.Method)); + + const result = mergeTestMethods([classA, b1, b2]); + + assert.deepStrictEqual(result, [[classA], [b1, b2]]); + }); +}); From 558d4441b3993c58e5acfacd981e94c7396d58ac Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Thu, 7 May 2026 21:31:56 +0800 Subject: [PATCH 08/10] perf: shorten commetn --- .../JUnitLaunchConfigurationDelegate.java | 21 +++------- .../plugin/launchers/JUnitLaunchUtils.java | 30 +++++--------- src/constants.ts | 13 ++----- src/controller/testController.ts | 39 +++++++------------ 4 files changed, 33 insertions(+), 70 deletions(-) diff --git a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java index 2a790d11..8efd048a 100644 --- a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java +++ b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java @@ -146,16 +146,9 @@ private void addTestItemArgs(List arguments) throws CoreException { } else if (this.args.testLevel == TestLevel.METHOD) { if (this.args.testNames.length > 1) { if (!JUnitLaunchUtils.supportsMultiMethodLaunch()) { - // The Class:method protocol is parsed by RemoteTestRunner inside - // org.eclipse.jdt.junit.runtime, which ships with the Eclipse Java - // Language Server (Language Support for Java(TM) by Red Hat). When - // that bundle predates eclipse.jdt.ui#2975, batching multiple - // methods into a single JVM would surface as a ClassNotFoundException - // at test time. Fail fast here with a marker the TypeScript side - // recognises so it can transparently fall back to launching each - // method in its own JVM (the legacy per-method path). The actionable - // text after the marker is preserved as a defensive fallback in case - // the fallback path itself also fails for an unrelated reason. + // Bundled org.eclipse.jdt.junit.runtime predates eclipse.jdt.ui#2975. + // Fail fast with a marker so the TypeScript side can fall back to + // per-method launches transparently. throw new CoreException(new Status(IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR, JUnitLaunchUtils.MULTI_METHOD_LAUNCH_UNSUPPORTED_PREFIX + "Running multiple test methods together in a single JVM requires a newer " @@ -164,11 +157,9 @@ private void addTestItemArgs(List arguments) throws CoreException { + "extension and retry, or run the selected methods one at a time.", null)); } - // Multi-method launch: hand the full selection to RemoteTestRunner via - // -testNameFile using the new "Class:method" line format. The runner - // will then load every selected method inside a single test JVM, so - // per-class @BeforeAll/@AfterAll and any cached Spring - // ApplicationContext are reused across the selection. + // Multi-method launch via "Class:method" lines: all selected methods share + // one JVM so per-class @BeforeAll/@AfterAll and cached fixtures (e.g. + // Spring ApplicationContext) are reused. See issue #1836. final String fileName = createMethodTestNamesFile(this.args.testNames); arguments.add("-testNameFile"); arguments.add(fileName); diff --git a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchUtils.java b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchUtils.java index 05109851..f09703a3 100644 --- a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchUtils.java +++ b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchUtils.java @@ -55,30 +55,24 @@ public class JUnitLaunchUtils { private static final String JUNIT4_LOADER = "org.eclipse.jdt.junit.loader.junit4"; /** - * Bundle that hosts {@code RemoteTestRunner}, the consumer of the - * {@code -testNameFile} content. This jar is shipped by the Eclipse Java - * Language Server (i.e. the "Language Support for Java(TM) by Red Hat" - * extension), not by vscode-java-test itself. + * Bundle that hosts {@code RemoteTestRunner}, the consumer of {@code -testNameFile}. + * Shipped by the Eclipse Java Language Server, not by vscode-java-test itself. */ private static final String JUNIT_RUNTIME_BUNDLE = "org.eclipse.jdt.junit.runtime"; /** - * Stable prefix prepended to the {@link CoreException} thrown when the - * resolved {@code org.eclipse.jdt.junit.runtime} bundle is too old to - * understand the {@code Class:method} multi-method launch protocol. The - * TypeScript side detects this prefix and silently falls back to launching - * every selected method in its own JVM (the legacy per-method path), - * keeping the user experience identical to the pre-batching behaviour on - * older Eclipse Java Language Server releases. Do NOT change the value - * without updating the corresponding constant on the client side. + * Marker prepended to the launch-resolution error when the bundled + * {@code org.eclipse.jdt.junit.runtime} is too old for the + * {@code Class:method} multi-method launch protocol. Detected by the + * TypeScript side to fall back to per-method launches. Keep in sync with + * the constant on the client side. */ public static final String MULTI_METHOD_LAUNCH_UNSUPPORTED_PREFIX = "MULTI_METHOD_LAUNCH_UNSUPPORTED: "; /** - * Minimum {@code org.eclipse.jdt.junit.runtime} version that recognises the - * {@code Class:method} multi-method launch protocol introduced by - * eclipse.jdt.ui#2975. + * Minimum {@code org.eclipse.jdt.junit.runtime} version that supports the + * {@code Class:method} multi-method launch protocol (eclipse.jdt.ui#2975). */ private static final Version MIN_JDT_JUNIT_RUNTIME_VERSION_FOR_MULTI_METHOD = Version.parseVersion("3.8.100"); @@ -87,11 +81,7 @@ private JUnitLaunchUtils() {} /** * @return {@code true} when the resolved {@code org.eclipse.jdt.junit.runtime} - * bundle is new enough to parse the {@code Class:method} multi-method launch - * protocol; {@code false} otherwise. When this returns {@code false}, callers - * must not batch multiple methods into a single JVM via the - * {@code -testNameFile} mechanism — the legacy per-method launch path should - * be used instead. + * supports batching multiple methods into a single JVM via {@code -testNameFile}. */ public static boolean supportsMultiMethodLaunch() { final Bundle bundle = Platform.getBundle(JUNIT_RUNTIME_BUNDLE); diff --git a/src/constants.ts b/src/constants.ts index c0c92857..c0fc7c8d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -68,16 +68,11 @@ export namespace Context { export namespace JUnitLaunchProtocol { /** - * Stable prefix prepended to the launch-resolution error thrown by the - * Java plugin when the bundled {@code org.eclipse.jdt.junit.runtime} + * Marker prepended to the launch-resolution error when the bundled JDT-LS * predates the {@code Class:method} multi-method launch protocol - * introduced by eclipse.jdt.ui#2975. The TypeScript runner detects this - * prefix and silently re-launches every selected method in its own JVM - * (the legacy per-method path), keeping the user experience identical to - * the pre-batching behaviour on older Eclipse Java Language Server - * releases. Keep in sync with - * {@code JUnitLaunchUtils.MULTI_METHOD_LAUNCH_UNSUPPORTED_PREFIX} on the - * Java side. + * (eclipse.jdt.ui#2975). Detected by the runner to fall back silently to + * per-method launches. Must match + * {@code JUnitLaunchUtils.MULTI_METHOD_LAUNCH_UNSUPPORTED_PREFIX} on the Java side. */ export const MULTI_METHOD_LAUNCH_UNSUPPORTED_PREFIX: string = 'MULTI_METHOD_LAUNCH_UNSUPPORTED: '; } diff --git a/src/controller/testController.ts b/src/controller/testController.ts index 3753ff48..fe59c285 100644 --- a/src/controller/testController.ts +++ b/src/controller/testController.ts @@ -265,22 +265,17 @@ export const runTests: (request: TestRunRequest, option: IRunOption) => any = in if (typeof error?.message === 'string' && error.message.startsWith(JUnitLaunchProtocol.MULTI_METHOD_LAUNCH_UNSUPPORTED_PREFIX) && testContext.testItems.length > 1) { - // Silent fallback for older Eclipse Java Language Server - // releases (predating eclipse.jdt.ui#2975): re-launch every - // selected method in its own JVM. From here on the cancel - // handler must defer to the per-item debug sessions, just - // as it would for a normal multi-group run. + // Silent fallback for legacy JDT-LS (pre eclipse.jdt.ui#2975): + // re-launch every selected method in its own JVM. delegatedToDebugger = true; const itemsToRetry: TestItem[] = [...testContext.testItems]; for (const item of itemsToRetry) { if (token.isCancellationRequested) { break; } - // Each per-item launch hands its progress to the debugger - // via __progressId, and the debugger calls done() when the - // session ends. The next iteration must therefore obtain a - // fresh progress reporter — mirroring the same isCancelled - // reset that the outer per-kind loop performs at line ~240. + // Each per-item launch hands progress to the debugger via + // __progressId; the debugger dones the reporter on session + // end. Reset like the outer per-kind loop does (~line 240). if (option.progressReporter?.isCancelled()) { option.progressReporter = progressProvider?.createProgressReporter(option.isDebug ? 'Debug Tests' : 'Run Tests'); } @@ -682,11 +677,10 @@ export function mergeTestMethods(testItems: TestItem[]): TestItem[][] { // expor && !([...methods].some((m: TestItem) => dataCache.get(m)?.uniqueId))) { classMapping.set(clazz.id, clazz); } else { - // Methods restricted to a single invocation (uniqueId) must still run in their - // own launch since the underlying protocol carries at most one uniqueId per JVM. - // Every other method of the same class can share one launch so that - // @BeforeAll / @AfterAll and any cached fixture (e.g. Spring ApplicationContext) - // are reused across the selection. See issue #1836. + // uniqueId methods must run alone (the protocol carries at most one + // uniqueId per JVM); the rest can share a JVM so @BeforeAll/@AfterAll + // and cached fixtures (e.g. Spring ApplicationContext) are reused. + // See issue #1836. const groupable: TestItem[] = []; for (const method of methods.values()) { if (dataCache.get(method)?.uniqueId) { @@ -753,14 +747,9 @@ function getRunnerByContext(testContext: IRunTestContext): BaseRunner | undefine } /** - * Run a single test item through its own setup → resolve → run → tearDown - * cycle. Used as a silent fallback when the bundled JDT-LS does not yet - * understand the {@code Class:method} multi-method launch protocol - * (eclipse.jdt.ui#2975) — every selected method is re-launched in its own JVM, - * matching the pre-batching behaviour. Errors during the fallback are - * surfaced per-item so unrelated failures (e.g. compilation errors) are still - * reported to the user; the multi-method marker itself is filtered out so the - * scary "requires a newer..." text never reaches the popup. + * Run a single test item through its own setup → resolve → run → tearDown cycle. + * Used as the silent fallback when the bundled JDT-LS lacks the + * {@code Class:method} multi-method protocol (eclipse.jdt.ui#2975). */ async function runItemInIsolatedLaunch( item: TestItem, @@ -787,9 +776,7 @@ async function runItemInIsolatedLaunch( await runner.run(resolvedConfiguration, token, option.progressReporter); } catch (error) { const rawMessage: string = error?.message || 'Failed to run tests.'; - // Strip the internal marker if it ever bubbles up here (e.g. the user - // somehow ends up with a single-method batch that still hits the - // capability gate) so the popup stays user-readable. + // Strip the internal marker if it ever bubbles up here so the popup stays user-readable. const message: string = rawMessage.startsWith(JUnitLaunchProtocol.MULTI_METHOD_LAUNCH_UNSUPPORTED_PREFIX) ? rawMessage.substring(JUnitLaunchProtocol.MULTI_METHOD_LAUNCH_UNSUPPORTED_PREFIX.length) : rawMessage; From ea979836c74b2567fe30176e6fa572279a072f30 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Fri, 8 May 2026 10:54:45 +0800 Subject: [PATCH 09/10] fix: lint --- .../launchers/JUnitLaunchConfigurationDelegate.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java index 8efd048a..362d3fa2 100644 --- a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java +++ b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java @@ -150,11 +150,11 @@ private void addTestItemArgs(List arguments) throws CoreException { // Fail fast with a marker so the TypeScript side can fall back to // per-method launches transparently. throw new CoreException(new Status(IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR, - JUnitLaunchUtils.MULTI_METHOD_LAUNCH_UNSUPPORTED_PREFIX - + "Running multiple test methods together in a single JVM requires a newer " - + "Eclipse Java Language Server (org.eclipse.jdt.junit.runtime). " - + "Please update the 'Language Support for Java(TM) by Red Hat' " - + "extension and retry, or run the selected methods one at a time.", + JUnitLaunchUtils.MULTI_METHOD_LAUNCH_UNSUPPORTED_PREFIX + + "Running multiple test methods together in a single JVM requires a newer " + + "Eclipse Java Language Server (org.eclipse.jdt.junit.runtime). " + + "Please update the 'Language Support for Java(TM) by Red Hat' " + + "extension and retry, or run the selected methods one at a time.", null)); } // Multi-method launch via "Class:method" lines: all selected methods share From 631625fab00c601e801a26c9ae4de5043390c924 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Fri, 8 May 2026 11:07:48 +0800 Subject: [PATCH 10/10] fix comments --- .../JUnitLaunchConfigurationDelegate.java | 2 +- src/controller/testController.ts | 17 ++++++++++++----- .../testController.mergeTestMethods.test.ts | 2 +- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java index 362d3fa2..65cfffbb 100644 --- a/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java +++ b/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/launchers/JUnitLaunchConfigurationDelegate.java @@ -200,7 +200,7 @@ private String resolveMethodTestName(String handleId) throws CoreException { .getType().resolveBinding(); if (paramTypeBinding == null) { throw new CoreException(new Status(IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR, - "Cannot set set argument for method" + methodDeclaration.toString(), null)); + "Cannot set argument for method" + methodDeclaration.toString(), null)); } else if (paramTypeBinding.isPrimitive()) { parameters.add(paramTypeBinding.getQualifiedName()); } else { diff --git a/src/controller/testController.ts b/src/controller/testController.ts index fe59c285..a2c20713 100644 --- a/src/controller/testController.ts +++ b/src/controller/testController.ts @@ -274,8 +274,8 @@ export const runTests: (request: TestRunRequest, option: IRunOption) => any = in break; } // Each per-item launch hands progress to the debugger via - // __progressId; the debugger dones the reporter on session - // end. Reset like the outer per-kind loop does (~line 240). + // __progressId; the debugger calls done() on the reporter + // on session end. Reset like the outer per-kind loop does (~line 240). if (option.progressReporter?.isCancelled()) { option.progressReporter = progressProvider?.createProgressReporter(option.isDebug ? 'Debug Tests' : 'Run Tests'); } @@ -622,9 +622,16 @@ function removeNonRerunTestInvocations(testItems: TestItem[]): void { } /** - * Eliminate the test methods if they are contained in the test class. - * Because the current test runner cannot run class and methods for the same time, - * in the returned array, all the classes are in one group and each method is a group. + * Eliminate the test methods if they are contained in the test class, then group the + * remaining method-level selections so they can share JVM launches where possible. + * + * The returned array is structured as: + * - The first group contains all class-level selections (run together). + * - Each subsequent group contains methods from the same parent class that can + * be launched in a single JVM, so per-class @BeforeAll/@AfterAll and cached + * fixtures (e.g. Spring ApplicationContext) are reused. See issue #1836. + * - Methods restricted to a single invocation (uniqueId) are kept in their own + * group, since the underlying protocol carries at most one uniqueId per JVM. */ export function mergeTestMethods(testItems: TestItem[]): TestItem[][] { // export for unit test if (testItems.length <= 1) { diff --git a/test/suite/testController.mergeTestMethods.test.ts b/test/suite/testController.mergeTestMethods.test.ts index 6fe00ddb..0f9ecb14 100644 --- a/test/suite/testController.mergeTestMethods.test.ts +++ b/test/suite/testController.mergeTestMethods.test.ts @@ -56,7 +56,7 @@ suite('testController - mergeTestMethods', () => { clazz.children.add(method2); clazz.children.add(method3); - // select only 2 of 3 methods - they must share one launch (the core PR #1862 change) + // select only 2 of 3 methods - they must share one launch (the core change for issue #1836) const result = mergeTestMethods([method1, method2]); assert.deepStrictEqual(result, [[], [method1, method2]]);