fix(java): explain recovery for unimported launch modules (#585)

Java launch preparation forwarded jdtls project-lookup failures unchanged, so a Maven module missing from the parent build or a wrongly inferred module showed a raw jdtls error in every Run and Debug entry point. Launch preparation now names the selected module and explains how to recover: wait for import, check the run configuration and the parent build, or open the folder as a project.

Covers the "does not belong to any Java project", "references non-existing project" and "not a Java project" responses. Cancellation, missing server, crash, timeout and build errors keep their existing handling. Import membership and module inference are unchanged.

Refs #197
This commit is contained in:
l0ng-ai
2026-10-31 23:56:19 +08:00
committed by GitHub
parent 369c63979d
commit 9dc736de09
4 changed files with 92 additions and 6 deletions
@@ -118,6 +118,30 @@ it("still reports a real resolve failure in the preparation console", async () =
expect(ipc.debugStart).not.toHaveBeenCalled();
});
it.each([
"jdtls: Given URI does not belong to any Java project.",
"jdtls: Launch configuration 1793460461218 references non-existing project parent.",
])("explains how to import a module when Debug fails with %s", async (message) => {
const starting = executeAction("debug.startConfig", { ...config, module: "web" });
await vi.waitFor(() => expect(ipc.javaResolveLaunch).toHaveBeenCalledOnce());
respond({ status: "error", error: { kind: "protocol", message } });
await starting;
expect(notify).toHaveBeenLastCalledWith(
expect.objectContaining({
title: "Cannot start debugging",
body: expect.stringContaining("Java module 'web' is not imported."),
}),
);
expect(
Object.values(useDebug.getState().console)
.flat()
.map((line) => line.text)
.join("\n"),
).toContain("open its folder as a project");
expect(ipc.javaDebugServer).not.toHaveBeenCalled();
expect(ipc.debugStart).not.toHaveBeenCalled();
});
it("keeps progress and the originating configuration when DAP takes over", async () => {
const starting = executeAction("debug.startConfig", config);
await vi.waitFor(() => expect(ipc.javaResolveLaunch).toHaveBeenCalledOnce());
+51 -4
View File
@@ -1,9 +1,10 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { RunConfig } from "@/platform/ipc";
import { findJavaModule } from "@/platform/javaLaunch";
import { IpcError, type RunConfig } from "@/platform/ipc";
import { findJavaModule, prepareJavaLaunch } from "@/platform/javaLaunch";
import { _resetI18nForTests, setLocale } from "@/platform/i18n";
import { provideNotificationService } from "@/workbench/contributions";
import { applicationConfig } from "./application";
import { resetRuns, saveUserConfigs, startRun } from "./store";
import { resetRuns, runtime, saveUserConfigs, startRun } from "./store";
import { applicationType } from "./jvmTypes";
import { withParams } from "./types";
@@ -63,7 +64,10 @@ beforeEach(() => {
vi.clearAllMocks();
provideNotificationService({ notify: vi.fn() });
});
afterEach(() => resetRuns());
afterEach(() => {
resetRuns();
_resetI18nForTests();
});
it.each(["sibling", "/outside/modules"])("runs from %s using the stored module's classpath", async (cwd) => {
const session = await startRun({ ...config, module: "app", cwd });
@@ -112,3 +116,46 @@ it("does not substitute the working directory when sources are missing or ambigu
});
expect(await findJavaModule(config)).toBeNull();
});
it.each([
"jdtls: Given URI does not belong to any Java project.",
"jdtls: Launch configuration 1793460461218 references non-existing project parent.",
])("explains how to import a module when Run fails with %s", async (message) => {
ipc.javaResolveLaunch.mockResolvedValueOnce({ status: "error", error: { kind: "protocol", message } } as never);
const session = await startRun({ ...config, module: "web" });
expect(session?.status).toBe("failed");
const output = runtime(session!.id)!
.console.lines.map((line) => line.text)
.join("\n");
expect(output).toContain("Java module 'web' is not imported.");
expect(output).toContain("parent Maven or Gradle build");
expect(output).toContain("open its folder as a project");
expect(ipc.runStart).not.toHaveBeenCalled();
});
it.each([
["unavailable", "jdtls is not installed (Settings › Languages & Frameworks › Language Servers)"],
["unavailable", "jdtls exited with code 1: wrong Java version"],
["protocol", "jdtls: workspace/executeCommand timed out after 90 s"],
["internal", "Build failed"],
["cancelled", "jdtls: Given URI does not belong to any Java project."],
] as const)("preserves other launch errors (%s: %s)", async (kind, message) => {
const error = new IpcError({ kind, message });
ipc.javaResolveLaunch.mockRejectedValueOnce(error);
await expect(
prepareJavaLaunch({ ...config, module: "web" }, { request: 1, signal: new AbortController().signal }),
).rejects.toBe(error);
});
it("translates import recovery guidance for a root Spring Boot configuration", async () => {
await setLocale("zh");
ipc.javaResolveLaunch.mockRejectedValueOnce(
new IpcError({ kind: "protocol", message: "jdtls: Given URI does not belong to any Java project." }),
);
await expect(
prepareJavaLaunch(
{ ...config, kind: "spring-boot", module: "" },
{ request: 1, signal: new AbortController().signal },
),
).rejects.toThrow("Java 模块 '.' 尚未导入");
});
+16 -2
View File
@@ -4,7 +4,7 @@
* `${classpath}` (or `${testClasspath}`) and the `java` launcher are resolved
* through jdtls when the configuration starts.
*/
import { call, commands, type JavaLaunchInfo, type RunConfig, type RequestId } from "@/platform/ipc";
import { call, commands, IpcError, type JavaLaunchInfo, type RunConfig, type RequestId } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
export const CLASSPATH = "${classpath}";
@@ -167,7 +167,21 @@ export async function prepareJavaLaunch(config: RunConfig, preparation: JavaPrep
const module = await resolveJavaModule(config);
signal.throwIfAborted();
const moduleMs = performance.now() - started;
const info = await call(commands.javaResolveLaunch(module, parsed.classpath === "test", true, preparation.request));
const info = await call(
commands.javaResolveLaunch(module, parsed.classpath === "test", true, preparation.request),
).catch((error: unknown) => {
if (
error instanceof IpcError &&
error.kind !== "cancelled" &&
/^jdtls:.*(?:does not belong to any java project|references non-existing project|not a java project)/i.test(
error.message,
)
)
throw new Error(
tx`Java module '${module || "."}' is not imported. Wait for Java project import to finish. If it still fails, check the run configuration's module and whether it is included in the parent Maven or Gradle build, or open its folder as a project.`,
);
throw error;
});
signal.throwIfAborted();
console.info("Java launch preparation", { module, moduleMs, resolveMs: performance.now() - started - moduleMs });
if (info.build === "withErrors")
+1
View File
@@ -18,6 +18,7 @@
"Security": "安全",
"Remove Trusted Location": "移除受信任的位置",
"Cannot determine the module for main class '{value}'": "无法确定主类 '{value}' 所属的模块",
"Java module '{value}' is not imported. Wait for Java project import to finish. If it still fails, check the run configuration's module and whether it is included in the parent Maven or Gradle build, or open its folder as a project.": "Java 模块 '{value}' 尚未导入。请等待 Java 项目导入完成。如果仍然失败,请检查运行配置选择的模块,以及该模块是否已包含在父级 Maven 或 Gradle 构建中,或将其文件夹作为项目打开。",
"Attach": "附加",
"Remove from Workspace": "从工作区移除",
"Downloading Java {0} language server runtime": "正在下载 Java {0} 语言服务器运行时",