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 */);