From 60622e3b9fb3d49bfe29a0fd0552facfd9084c43 Mon Sep 17 00:00:00 2001 From: Martin Lippert Date: Mon, 24 Aug 2026 11:28:14 +0200 Subject: [PATCH] Ignore @SpringBootApplication classes in test source folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `vscode.java.resolveMainClass` searches every source folder of a project, so a `@SpringBootApplication` class copied into `src/test/java` — a common pattern for integration tests — was offered as an additional launch candidate. Running an app then popped up a quick pick asking the user to choose between the real main class and one that is irrelevant to launch. Filter those out by matching each resolved main class against the test source folders of the project's classpath. The Spring Tools classpath listener already reports `isTest` for each source entry (set from `IClasspathEntry.isTest()`), so no extra language server round trip is needed; the flag was just missing from the `CPE` type declaration. The unfiltered list stays available via `getMainClasses()` so that live processes launched from a test main class are still associated with their app. When every main class sits in a test source folder, the filter falls back to the full list to keep such a project launchable. Closes #420 --- src/BootApp.ts | 15 ++++++++++++++- src/LocalAppController.ts | 2 +- src/types/jdtls.d.ts | 1 + src/utils.ts | 34 ++++++++++++++++++++++++++++++++++ test/suite/extension.test.ts | 29 +++++++++++++++++++++++++++-- 5 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/BootApp.ts b/src/BootApp.ts index 92ec596..4aba8a6 100644 --- a/src/BootApp.ts +++ b/src/BootApp.ts @@ -5,7 +5,7 @@ import * as vscode from "vscode"; import { dashboard } from "./global"; import { requestWorkspaceSymbols } from "./models/stsApi"; import { ClassPathData, MainClassData } from "./types/jdtls"; -import { isActuatorJarFile, isAlive } from "./utils"; +import { excludeTestMainClasses, isActuatorJarFile, isAlive } from "./utils"; import { BootAppItem } from "./views/items/BootAppItem"; import * as lsp from "vscode-languageclient"; import * as async from "async"; @@ -187,6 +187,19 @@ export class BootApp { return this.mainClasses; } + /** + * The main classes offered to the user when running/debugging this app, i.e. all + * resolved main classes except the ones located in test source folders. + * + * Falls back to the full list when every main class is in a test source folder, so + * that a project whose only main class lives there stays launchable. + */ + public async getLaunchableMainClasses(): Promise { + const mainClasses = await this.getMainClasses(); + const launchable = excludeTestMainClasses(mainClasses, this.classpath); + return launchable.length > 0 ? launchable : mainClasses; + } + /** * getWorkspaceSymbols */ diff --git a/src/LocalAppController.ts b/src/LocalAppController.ts index b577774..4ca7918 100644 --- a/src/LocalAppController.ts +++ b/src/LocalAppController.ts @@ -45,7 +45,7 @@ export class LocalAppController { const mainClasData = await vscode.window.withProgress( { location: vscode.ProgressLocation.Window, title: `Resolving main classes for ${app.name}...` }, async () => { - const mainClassList = await app.getMainClasses(); + const mainClassList = await app.getLaunchableMainClasses(); if (mainClassList && mainClassList instanceof Array && mainClassList.length > 0) { return mainClassList.length === 1 ? mainClassList[0] : diff --git a/src/types/jdtls.d.ts b/src/types/jdtls.d.ts index fa0548a..94e7de3 100644 --- a/src/types/jdtls.d.ts +++ b/src/types/jdtls.d.ts @@ -15,4 +15,5 @@ interface CPE { sourceContainerUrl: string; javadocContainerUrl: string; isSystem: boolean; + isTest: boolean; } diff --git a/src/utils.ts b/src/utils.ts index 2029a72..e009727 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,6 +4,7 @@ import * as path from "path"; import { Readable } from "stream"; import * as vscode from "vscode"; +import { ClassPathData, MainClassData } from "./types/jdtls"; import pidtree = require("pidtree"); export function readAll(input: Readable): Promise { @@ -48,6 +49,39 @@ export function isActuatorJarFile(f: string): boolean { return false; } +/** + * Whether `filePath` is located inside `folder`. Both are expected to be + * absolute file system paths. + */ +function isInFolder(filePath: string, folder: string): boolean { + const relative = path.relative(folder, filePath); + // An empty result means both point at the same location, ".." means filePath + // is outside, and an absolute result means they are on different drives. + return !!relative && !relative.startsWith("..") && !path.isAbsolute(relative); +} + +/** + * Drops the main classes that live in a test source folder of the project. + * + * `vscode.java.resolveMainClass` searches all source folders, so a + * `@SpringBootApplication` class copied into `src/test/java` (a common pattern for + * integration tests) shows up as an additional candidate to launch. Those classes + * are not what users want to run from the dashboard, and offering them turns a + * one-click "run" into a quick pick with irrelevant choices. + * + * See https://github.com/microsoft/vscode-spring-boot-dashboard/issues/420 + */ +export function excludeTestMainClasses(mainClasses: MainClassData[], classpath: ClassPathData): MainClassData[] { + const testSourceFolders = (classpath?.entries ?? []) + .filter(cpe => cpe.kind === "source" && cpe.isTest) + .map(cpe => cpe.path); + if (testSourceFolders.length === 0) { + return mainClasses; + } + // Keep entries without a file path: they cannot be located, so they cannot be ruled out. + return mainClasses.filter(mc => !mc.filePath || !testSourceFolders.some(folder => isInFolder(mc.filePath, folder))); +} + /** * Construct URL based on format defined in spring.dashboard.openUrl * diff --git a/test/suite/extension.test.ts b/test/suite/extension.test.ts index 1178e87..00c3413 100644 --- a/test/suite/extension.test.ts +++ b/test/suite/extension.test.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. import * as assert from "assert"; +import * as path from "path"; import * as vscode from "vscode"; import { AppState } from "../../src/BootApp"; import { initSymbols } from "../../src/controllers/SymbolsController"; @@ -69,6 +70,30 @@ suite("Extension Test Suite", () => { assert.strictEqual(openedEditor?.selection.anchor.character, 1, "The definition of CrashController should be at character 1."); }).timeout(300 * 1000 /** ms */); + test("Should not offer main classes from test source folders", async () => { + let apps = dashboard.appsProvider.manager.getAppList(); + while (apps.length === 0) { + console.log("waiting until the app list is populated"); + await sleep(5 * 1000 /** ms */); + apps = dashboard.appsProvider.manager.getAppList(); + } + const app = apps[0]; + + // petclinic declares a main method in PetClinicApplication plus three more in + // src/test/java (MysqlTestApplication, PetClinicIntegrationTests, PostgresIntegrationTests). + const allMainClasses = await app.getMainClasses(); + const testMainClasses = allMainClasses.filter(c => c.filePath.includes(path.join("src", "test", "java"))); + assert.ok(testMainClasses.length > 0, `There should be main classes in test source folders, but got ${JSON.stringify(allMainClasses)}.`); + + // Only the one in src/main/java is a launch candidate. https://github.com/microsoft/vscode-spring-boot-dashboard/issues/420 + const launchable = await app.getLaunchableMainClasses(); + assert.deepStrictEqual( + launchable.map(c => c.mainClass), + ["org.springframework.samples.petclinic.PetClinicApplication"], + "Only the main class in src/main/java should be launchable." + ); + }).timeout(300 * 1000 /** ms */); + test("Can view dynamic beans and mappings", async function() { // Skip on CI — launching the app is unreliable in headless environments // (wmic ENOENT on Windows Server 2025, debug session failures on Linux/macOS). @@ -81,8 +106,8 @@ suite("Extension Test Suite", () => { assert.strictEqual(apps.length, 1, "There are 1 app in the app list."); const app = apps[0]; - // Filter to PetClinicApplication to avoid QuickPick when multiple main classes exist - app.mainClasses = app.mainClasses?.filter(c => c.mainClass.includes("PetClinicApplication")); + // No QuickPick is expected: petclinic's other main classes all live in test + // source folders, so PetClinicApplication is the only launch candidate. await vscode.commands.executeCommand("spring-boot-dashboard.localapp.run", app); while (app.state !== AppState.RUNNING) { await sleep(5 * 1000 /** ms */);