Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/BootApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<MainClassData[]> {
const mainClasses = await this.getMainClasses();
const launchable = excludeTestMainClasses(mainClasses, this.classpath);
return launchable.length > 0 ? launchable : mainClasses;
}

/**
* getWorkspaceSymbols
*/
Expand Down
2 changes: 1 addition & 1 deletion src/LocalAppController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] :
Expand Down
1 change: 1 addition & 0 deletions src/types/jdtls.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ interface CPE {
sourceContainerUrl: string;
javadocContainerUrl: string;
isSystem: boolean;
isTest: boolean;
}
34 changes: 34 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
Expand Down Expand Up @@ -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
*
Expand Down
29 changes: 27 additions & 2 deletions test/suite/extension.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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).
Expand All @@ -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 */);
Expand Down
Loading