From 9dc736de09be835f7dca57d1bfd58708a14a9c29 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:56:19 +0800 Subject: [PATCH] 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 --- .../debugger/__tests__/javaCancel.test.ts | 24 ++++++++ src/features/run/javaModule.test.ts | 55 +++++++++++++++++-- src/platform/javaLaunch.ts | 18 +++++- src/platform/locales/text/zh.json | 1 + 4 files changed, 92 insertions(+), 6 deletions(-) diff --git a/src/features/debugger/__tests__/javaCancel.test.ts b/src/features/debugger/__tests__/javaCancel.test.ts index b4b1282a..5c77ce6d 100644 --- a/src/features/debugger/__tests__/javaCancel.test.ts +++ b/src/features/debugger/__tests__/javaCancel.test.ts @@ -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()); diff --git a/src/features/run/javaModule.test.ts b/src/features/run/javaModule.test.ts index 4856b831..d0193958 100644 --- a/src/features/run/javaModule.test.ts +++ b/src/features/run/javaModule.test.ts @@ -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 模块 '.' 尚未导入"); +}); diff --git a/src/platform/javaLaunch.ts b/src/platform/javaLaunch.ts index ee2bf54d..91d9cf6d 100644 --- a/src/platform/javaLaunch.ts +++ b/src/platform/javaLaunch.ts @@ -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") diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 52e650e9..5878ce62 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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} 语言服务器运行时",