fix(build): link missing tool and JDK errors to recovery (#2335)

Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-09 18:15:24 +08:00
1 джерело e2f22de824
коміт 0b1f723b9a
11 files changed
+260 -8

No files matched your search

+6
Переглянути файл
@@ -268,6 +268,12 @@ impl Handler for ClientHandler {
"window/showMessage" => self.show_message(&mgr, &params),
"window/logMessage" => {
let text = params.get("message").and_then(Value::as_str).unwrap_or_default();
if self.server.name == "jdtls"
&& text.contains("Can't use Java ")
&& text.contains(" to import Gradle project ")
{
self.show_message(&mgr, &json!({ "type": 1, "message": text.lines().next().unwrap_or(text) }));
}
match params.get("type").and_then(Value::as_u64) {
Some(1) if self.server.name == "jdtls" && text.contains(crate::jdtls::reserved_names::IMPORT_FAILURE) => {
tracing::warn!("[{}] {text}", self.server.name);
+33
Переглянути файл
@@ -5606,6 +5606,39 @@ async fn jdtls_import_failure_names_reserved_device_files_on_windows() {
let _ = std::fs::remove_file(&nul);
}
#[tokio::test(flavor = "multi_thread")]
async fn gradle_incompatible_jdk_log_is_surfaced_once_across_message_channels() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
let s = h.launcher.state("jdtls");
h.open("A.java", "class A {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
let failure = "Can't use Java 27.0.1.0.3 and Gradle 9.7.1 to import Gradle project kotlin-gradle.";
for _ in 0..2 {
s.peer()
.notify(
"window/logMessage",
json!({ "type": 1, "message": format!("{failure}\njava.lang.Exception: details stay in the log") }),
)
.unwrap();
}
eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::Notify { body: Some(b), .. } if b == failure)))
.await;
s.peer().notify("window/showMessage", json!({ "type": 1, "message": failure })).unwrap();
s.peer().notify("window/logMessage", json!({ "type": 1, "message": "unrelated internal error" })).unwrap();
s.peer().notify("window/showMessage", json!({ "type": 2, "message": "after" })).unwrap();
eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::Notify { body: Some(b), .. } if b == "after")))
.await;
let events = h.events.lock();
let messages: Vec<_> = events
.iter()
.filter_map(|e| match e {
IdeEvent::Notify { body: Some(body), .. } => Some(body.as_str()),
_ => None,
})
.collect();
assert_eq!(messages, vec![failure, "after"]);
}
/// Pull Members Up / Push Members Down: the extension's answers, refusals and edits.
#[tokio::test(flavor = "multi_thread")]
async fn java_hierarchy_refactorings_use_the_extension_commands() {
+3 -4
Переглянути файл
@@ -23,7 +23,7 @@ import {
type RunConfig,
type SyncProgress,
} from "@/platform/ipc";
import { downloadJdkActions } from "@/platform/jdk";
import { buildToolRecovery } from "@/platform/buildToolRecovery";
import { useBuildModel } from "@/platform/buildModel";
import { useProject } from "@/platform/project";
import { editorService, notifications } from "@/workbench/contributions";
@@ -454,9 +454,8 @@ export async function syncTool(tool: BuildTool, buildDir: string | null = null):
group: "run",
level: "warning",
title: tool === "maven" ? "Maven sync finished with errors" : "Gradle sync failed",
body: error,
...buildToolRecovery(error),
key: `build-sync-${tool}`,
actions: downloadJdkActions(error),
});
} catch (e) {
if (!isCancelled(e))
@@ -464,7 +463,7 @@ export async function syncTool(tool: BuildTool, buildDir: string | null = null):
group: "run",
level: "error",
title: tool === "maven" ? "Cannot reload Maven projects" : "Cannot reload Gradle projects",
body: e instanceof Error ? e.message : String(e),
...buildToolRecovery(e instanceof Error ? e.message : String(e)),
key: `build-sync-${tool}`,
});
} finally {
+2 -1
Переглянути файл
@@ -27,6 +27,7 @@ import {
prepareJavaLaunch,
} from "@/platform/javaLaunch";
import { downloadJdkActions } from "@/platform/jdk";
import { buildToolRecovery } from "@/platform/buildToolRecovery";
import { useProject } from "@/platform/project";
import { executeAction } from "@/platform/actions";
import { workflowSettings } from "@/platform/workflowSettings";
@@ -930,7 +931,7 @@ export async function startRun(
group: "run",
level: "error",
title: `Error running '${remoteLabel(config)}'`,
body: message,
...buildToolRecovery(message),
});
}
return getSession(sessionId) ?? null;
+47
Переглянути файл
@@ -0,0 +1,47 @@
import { afterEach, expect, it, vi } from "vitest";
const executeAction = vi.hoisted(() => vi.fn());
vi.mock("./actions", async (original) => ({
...(await original<typeof import("./actions")>()),
executeAction,
}));
import { buildToolRecovery } from "./buildToolRecovery";
afterEach(() => vi.clearAllMocks());
it.each([
["cannot start mvn: No such file or directory (os error 2)", "maven"],
["command not found: mvn", "maven"],
["command not found: /opt/apache maven/bin/mvn", "maven"],
["command not found: C:\\Maven\\bin\\mvn.cmd", "maven"],
["cannot start gradle: No such file or directory (os error 2)", "gradle"],
["command not found: gradle", "gradle"],
["command not found: C:\\Gradle\\bin\\gradle.bat", "gradle"],
["command not found: ./gradlew", "gradle"],
])("links missing build tool to its settings: %s", (message, tool) => {
const result = buildToolRecovery(message);
expect(result.body).toContain(`${tool === "maven" ? "Maven" : "Gradle"} could not be started`);
expect(result.body).toContain(message);
expect(result.actions).toHaveLength(1);
result.actions[0]!.run();
expect(executeAction).toHaveBeenCalledWith("file.settings", `build.${tool}`);
});
it("links the reported Gradle import rejection to the Gradle JVM picker and its JDK download", () => {
const message =
"Nov 9, 2026, 3:49:11 PM Can't use Java 27.0.1.0.3 and Gradle 9.7.1 to import Gradle project kotlin-gradle.";
const result = buildToolRecovery(message);
expect(result.body).toContain("Download JDK");
expect(result.actions[0]!.label).toBe("Choose Gradle JVM");
result.actions[0]!.run();
expect(executeAction).toHaveBeenCalledWith("file.settings", "build.gradle");
});
it.each([
"command not found: npm",
"Maven tests failed",
"Gradle build failed",
"cannot start mvn: Permission denied (os error 13)",
])("leaves unrelated failures alone: %s", (message) => {
expect(buildToolRecovery(message)).toEqual({ body: message, actions: [] });
});
+37
Переглянути файл
@@ -0,0 +1,37 @@
import { executeAction } from "./actions";
import { tx } from "./i18n";
import { downloadJdkActions } from "./jdk";
/** Recovery shared by build sync, run configurations and language-server import errors. */
export function buildToolRecovery(message: string) {
const actions = downloadJdkActions(message);
const incompatible = /Can't use Java .* and Gradle .* to import Gradle project/i.test(message);
const missing = /command not found: ([^\r\n]+)|cannot start ([^\r\n]+?): [^\r\n]*\(os error [23]\)/i.exec(message);
const command = (missing?.[1] ?? missing?.[2] ?? "").trim().replace(/^["'`]|["'`]$/g, "");
const namedTool = /(?:^|[/\\])(?:mvn|mvnw|mvnd)(?:\.(?:cmd|bat|exe))?$/i.test(command)
? "maven"
: /(?:^|[/\\])gradlew?(?:\.(?:bat|exe))?$/i.test(command)
? "gradle"
: undefined;
const recoveryTool = incompatible ? "gradle" : namedTool;
if (!recoveryTool) return { body: message, actions };
const label =
recoveryTool === "maven"
? tx("Open Maven Settings")
: incompatible
? tx("Choose Gradle JVM")
: tx("Open Gradle Settings");
actions.push({ label, run: () => void executeAction("file.settings", `build.${recoveryTool}`) });
const hint = incompatible
? tx(
"Choose a JDK compatible with this Gradle version in Gradle JVM. Download JDK is available in the JVM selector.",
)
: recoveryTool === "maven"
? tx(
"Maven could not be started. Configure Maven home in Maven settings, install Maven on PATH, or use a project with a Maven wrapper.",
)
: tx(
"Gradle could not be started. Configure a local installation in Gradle settings, install Gradle on PATH, or use a project with a Gradle wrapper.",
);
return { body: `${hint}\n\n${message}`, actions };
}
+7 -1
Переглянути файл
@@ -735,5 +735,11 @@
"Select a .NET project": "Select a .NET project",
"Project evaluation failed": "Project evaluation failed",
"dotnet exited with code {0}": "dotnet exited with code {0}",
"Microsoft.Testing.Platform was not found in the restored project.": "Microsoft.Testing.Platform was not found in the restored project."
"Microsoft.Testing.Platform was not found in the restored project.": "Microsoft.Testing.Platform was not found in the restored project.",
"Open Maven Settings": "Open Maven Settings",
"Open Gradle Settings": "Open Gradle Settings",
"Choose Gradle JVM": "Choose Gradle JVM",
"Choose a JDK compatible with this Gradle version in Gradle JVM. Download JDK is available in the JVM selector.": "Choose a JDK compatible with this Gradle version in Gradle JVM. Download JDK is available in the JVM selector.",
"Maven could not be started. Configure Maven home in Maven settings, install Maven on PATH, or use a project with a Maven wrapper.": "Maven could not be started. Configure Maven home in Maven settings, install Maven on PATH, or use a project with a Maven wrapper.",
"Gradle could not be started. Configure a local installation in Gradle settings, install Gradle on PATH, or use a project with a Gradle wrapper.": "Gradle could not be started. Configure a local installation in Gradle settings, install Gradle on PATH, or use a project with a Gradle wrapper."
}
+7 -1
Переглянути файл
@@ -7069,5 +7069,11 @@
"Run test": "运行测试",
"Project evaluation failed": "项目求值失败",
"dotnet exited with code {0}": "dotnet 已退出,退出码 {0}",
"Microsoft.Testing.Platform was not found in the restored project.": "还原后的项目中未找到 Microsoft.Testing.Platform。"
"Microsoft.Testing.Platform was not found in the restored project.": "还原后的项目中未找到 Microsoft.Testing.Platform。",
"Open Maven Settings": "打开 Maven 设置",
"Open Gradle Settings": "打开 Gradle 设置",
"Choose Gradle JVM": "选择 Gradle JVM",
"Choose a JDK compatible with this Gradle version in Gradle JVM. Download JDK is available in the JVM selector.": "请在 Gradle JVM 中选择与此 Gradle 版本兼容的 JDK。JVM 选择器中提供下载 JDK 选项。",
"Maven could not be started. Configure Maven home in Maven settings, install Maven on PATH, or use a project with a Maven wrapper.": "无法启动 Maven。请在 Maven 设置中配置 Maven 主目录,将 Maven 安装到 PATH,或使用带有 Maven wrapper 的项目。",
"Gradle could not be started. Configure a local installation in Gradle settings, install Gradle on PATH, or use a project with a Gradle wrapper.": "无法启动 Gradle。请在 Gradle 设置中配置本地安装,将 Gradle 安装到 PATH,或使用带有 Gradle wrapper 的项目。"
}
+22
Переглянути файл
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { emitLocalIdeEvent } from "@/platform/ipc";
import { setLocale } from "@/platform/i18n";
import { registerStatusItem, registerToolWindow, type StatusItemContribution } from "./contributions";
import { orderStatusItems, StatusBar, TaskStatus } from "./StatusBar";
import { orderTitleItems } from "./TitleBar";
@@ -200,6 +201,27 @@ describe("notifications", () => {
uninstall();
});
it.each(["en", "zh"])("adds Gradle JVM recovery before translating backend messages (%s)", async (locale) => {
await setLocale(locale);
const uninstall = installNotifyEvents();
try {
act(() =>
emitLocalIdeEvent({
type: "notify",
level: "error",
title: "jdtls",
body: "Can't use Java 27.0.1.0.3 and Gradle 9.7.1 to import Gradle project kotlin-gradle.",
}),
);
const item = useNotifications.getState().items[0]!;
expect(item.actions?.map((a) => a.label)).toEqual([locale === "zh" ? "选择 Gradle JVM" : "Choose Gradle JVM"]);
expect(item.body).toContain(locale === "zh" ? "下载 JDK" : "Download JDK");
} finally {
uninstall();
await setLocale("en");
}
});
it("the Notifications tool window lists history, marks it read and hides balloons", () => {
const dispose = registerToolWindow({
id: NOTIFICATIONS_TOOL_WINDOW,
+86
Переглянути файл
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { BuildModel, ProjectInfo, RunConfig } from "@/platform/ipc";
const mocks = vi.hoisted(() => ({ sync: vi.fn(), start: vi.fn(), notify: vi.fn() }));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return {
...real,
isTauri: () => true,
commands: {
...real.commands,
buildSync: mocks.sync,
runStart: mocks.start,
runConfigs: async () => ({ status: "ok", data: [] }),
},
};
});
vi.mock("@tauri-apps/api/core", async (original) => ({
...(await original<typeof import("@tauri-apps/api/core")>()),
Channel: class {
onmessage: unknown = null;
},
}));
import { useProject } from "@/platform/project";
import { provideNotificationService } from "@/workbench/contributions";
import { resetBuild, syncTool, useBuild } from "../features/build-tools/store";
import { resetRuns, startRun } from "../features/run/store";
beforeEach(() => {
vi.clearAllMocks();
useProject.setState({ project: { root: "/p", trusted: true } as ProjectInfo });
useBuild.setState({ root: "/p", syncing: {}, model: { maven: null, gradle: [] } });
provideNotificationService({ notify: mocks.notify });
});
afterEach(() => {
resetBuild();
resetRuns();
});
it.each(["maven", "gradle"] as const)("offers settings for %s sync failures, returned or thrown", async (tool) => {
const command = tool === "maven" ? "mvn" : "gradle";
const message = `cannot start ${command}: No such file or directory (os error 2)`;
mocks.sync.mockRejectedValueOnce(new Error(message));
await syncTool(tool);
expect(mocks.notify).toHaveBeenLastCalledWith(
expect.objectContaining({
actions: [expect.objectContaining({ label: `Open ${tool === "maven" ? "Maven" : "Gradle"} Settings` })],
body: expect.stringContaining(message),
}),
);
const model = (tool === "maven"
? { maven: { syncError: message }, gradle: [] }
: { maven: null, gradle: [{ syncError: message }] }) as unknown as BuildModel;
mocks.sync.mockResolvedValueOnce({ status: "ok", data: model });
await syncTool(tool);
expect(mocks.notify).toHaveBeenLastCalledWith(
expect.objectContaining({
actions: [expect.objectContaining({ label: `Open ${tool === "maven" ? "Maven" : "Gradle"} Settings` })],
}),
);
});
it.each(["maven", "gradle"] as const)("offers settings when a %s run cannot start", async (kind) => {
const command = kind === "maven" ? "mvn" : "gradle";
const config: RunConfig = {
id: kind,
name: `${command} test`,
kind,
command,
args: ["test"],
cwd: null,
env: {},
detected: true,
isTest: false,
debugAdapter: null,
};
mocks.start.mockRejectedValueOnce(new Error(`command not found: ${command}`));
await startRun(config, undefined, undefined, { prepared: true, show: false });
expect(mocks.start).toHaveBeenCalledOnce();
expect(mocks.notify).toHaveBeenLastCalledWith(
expect.objectContaining({
actions: [expect.objectContaining({ label: `Open ${kind === "maven" ? "Maven" : "Gradle"} Settings` })],
}),
);
});
+10 -1
Переглянути файл
@@ -2,6 +2,7 @@
import type { ReactNode } from "react";
import { create } from "zustand";
import { call, commands, onIdeEvent } from "@/platform/ipc";
import { buildToolRecovery } from "@/platform/buildToolRecovery";
import { txTemplate } from "@/platform/i18n";
import { useProject } from "@/platform/project";
import type { NotificationService } from "./contributions";
@@ -202,6 +203,14 @@ export function installNotifyEvents(): () => void {
return onIdeEvent(
"notify",
// Backend text may carry values (paths, names); `{name}` catalog templates translate it.
(e) => void notify({ level: e.level, title: txTemplate(e.title), body: e.body ? txTemplate(e.body) : undefined }),
(e) => {
const recovery = e.body ? buildToolRecovery(e.body) : undefined;
void notify({
level: e.level,
title: txTemplate(e.title),
...recovery,
body: recovery ? txTemplate(recovery.body) : undefined,
});
},
);
}