From f7c39c559c079425aa92ded6aee4f5521ebda90e Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Thu, 8 Oct 2026 10:28:21 +0800 Subject: [PATCH 01/15] fix: align tooling JDK requirements with redhat.java --- .vscodeignore | 1 + README.md | 6 + package.json | 1 + src/beginner-tips/assets/BeginnerTips.tsx | 4 +- src/beginner-tips/assets/index.tsx | 5 +- src/beginner-tips/assets/tabs/FaqPanel.tsx | 7 +- src/beginner-tips/index.ts | 3 +- src/java-runtime/assets/ProjectJDKPanel.tsx | 6 +- src/java-runtime/assets/ToolingJDKPanel.tsx | 7 +- src/java-runtime/assets/index.ts | 7 +- src/java-runtime/index.ts | 33 +- src/java-runtime/utils/upstreamApi.ts | 194 +++++----- tests/java-runtime.test.js | 375 ++++++++++++++++++++ 13 files changed, 530 insertions(+), 119 deletions(-) create mode 100644 tests/java-runtime.test.js diff --git a/.vscodeignore b/.vscodeignore index dad98cb8..4069824b 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -18,3 +18,4 @@ scripts test-fixtures/** test-plans/** test-results/** +tests/** diff --git a/README.md b/README.md index 5393758f..d89c94c0 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,12 @@ By installing Extension Pack for Java, the following extensions are installed: - [📦 Project Manager for Java](https://marketplace.visualstudio.com/items?itemName=vscjava.vscode-java-dependency) - Manage Java projects, referenced libraries, resource files, packages, classes, and class members +## Java Runtime Requirements + +The JDK used to launch the Java language server is separate from the JDK used to compile and run your projects. The tooling minimum depends on the installed Language Support for Java extension: `redhat.java` 1.57.0 and newer requires JDK 25+, versions 1.39.0-1.56.x require JDK 21+, and earlier supported versions require JDK 17+. A compatible bundled runtime satisfies the tooling requirement; Java 27 project support does not require JDK 27 just to launch the language server. + +Use **Java: Configure Java Runtime** to inspect the selected language-server runtime and configure project JDKs. An explicitly configured tooling JDK below the minimum is not used to launch the language server; the extension can fall back to a compatible bundled runtime or a discovered local JDK. Older project targets remain supported through `java.configuration.runtimes` and your Maven or Gradle build configuration. + ## Other Recommendations You can do more with VS Code. Here are some more recommendations that could help. diff --git a/package.json b/package.json index 3c5a5a6e..063d503d 100644 --- a/package.json +++ b/package.json @@ -338,6 +338,7 @@ "compile": "webpack --config webpack.config.js", "watch": "webpack --config webpack.config.js --watch", "build": "webpack --config webpack.config.js --mode=\"production\"", + "test": "node --test", "update-tpn": "node scripts/update-third-party-notice.js" }, "devDependencies": { diff --git a/src/beginner-tips/assets/BeginnerTips.tsx b/src/beginner-tips/assets/BeginnerTips.tsx index e4d39928..c2671618 100644 --- a/src/beginner-tips/assets/BeginnerTips.tsx +++ b/src/beginner-tips/assets/BeginnerTips.tsx @@ -10,7 +10,7 @@ import DebuggingPanel from "./tabs/DebuggingPanel"; import FaqPanel from "./tabs/FaqPanel"; import QuickStartPanel from "./tabs/QuickStartPanel"; -export default function BeginnerTips() { +export default function BeginnerTips({ requiredJdkVersion }: { requiredJdkVersion: number }) { return (
@@ -33,7 +33,7 @@ export default function BeginnerTips() { - +
diff --git a/src/beginner-tips/assets/index.tsx b/src/beginner-tips/assets/index.tsx index a0651c1c..f428dbae 100644 --- a/src/beginner-tips/assets/index.tsx +++ b/src/beginner-tips/assets/index.tsx @@ -4,5 +4,6 @@ import { createRoot } from 'react-dom/client'; import BeginnerTips from "./BeginnerTips"; -const root = createRoot(document.getElementById('root')!); -root.render(); +const container = document.getElementById('root')!; +const root = createRoot(container); +root.render(); diff --git a/src/beginner-tips/assets/tabs/FaqPanel.tsx b/src/beginner-tips/assets/tabs/FaqPanel.tsx index c183429a..feae896f 100644 --- a/src/beginner-tips/assets/tabs/FaqPanel.tsx +++ b/src/beginner-tips/assets/tabs/FaqPanel.tsx @@ -5,9 +5,8 @@ import "@vscode-elements/elements/dist/vscode-table/index.js"; import "@vscode-elements/elements/dist/vscode-table-row/index.js"; import "@vscode-elements/elements/dist/vscode-table-cell/index.js"; import "@vscode-elements/elements/dist/vscode-table-body/index.js"; -const REQUIRED_JDK_VERSION = 17; -export default function FaqPanel() { +export default function FaqPanel({ requiredJdkVersion }: { requiredJdkVersion: number }) { const runtimeSampleCode = ( {"\"java.configuration.runtimes\": [{"}
@@ -50,12 +49,12 @@ export default function FaqPanel() {

Why do I see the JDK errors?

- JDK {REQUIRED_JDK_VERSION}+ is required to run the Java language support (redhat.java) extension! You see this error because we failed to find one on your machine. The Configure Java Runtime guide can help you understand how JDK path is searched and provides download links if you need to install one. + JDK {requiredJdkVersion}+ is required to run the installed Java language support (redhat.java) extension. A compatible bundled runtime can satisfy this requirement; otherwise, a compatible local JDK is needed. You see this error because we failed to find one. The Configure Java Runtime guide can help you understand how JDK path is searched and provides download links if you need to install one.

Can I run my Java 8 project with JDK 1.8?

- Yes. The JDK {REQUIRED_JDK_VERSION} requirement is just for running the Java language support (redhat.java) extension itself. You can still configure a different runtime JDK for your project via the user setting "java.configuration.runtimes". The extension will pick a matching JDK to compile/run your project according to the compiler version specified by the project build file. + Yes. The JDK {requiredJdkVersion} requirement is just for running the Java language support (redhat.java) extension itself. You can still configure a different runtime JDK for your project via the user setting "java.configuration.runtimes". The extension will pick a matching JDK to compile/run your project according to the compiler version specified by the project build file.

{runtimeSampleCode} diff --git a/src/beginner-tips/index.ts b/src/beginner-tips/index.ts index 5ac6b427..f9d9e42e 100644 --- a/src/beginner-tips/index.ts +++ b/src/beginner-tips/index.ts @@ -5,6 +5,7 @@ import * as path from "path"; import * as vscode from "vscode"; import { sendInfo } from "vscode-extension-telemetry-wrapper"; import { getNonce, webviewCmdLinkHandler } from "../utils"; +import { getRequiredJdkVersion } from "../java-runtime/utils/upstreamApi"; const WEBVIEW_ID = "java.gettingStarted"; const WEBVIEW_TITLE = "Tips for Beginners"; @@ -122,7 +123,7 @@ class BeginnerTipsPage { -
+
diff --git a/src/java-runtime/assets/ProjectJDKPanel.tsx b/src/java-runtime/assets/ProjectJDKPanel.tsx index 3c67aeb0..bdbd19cf 100644 --- a/src/java-runtime/assets/ProjectJDKPanel.tsx +++ b/src/java-runtime/assets/ProjectJDKPanel.tsx @@ -19,9 +19,12 @@ import { onWillListRuntimes, openBuildScript } from "./vscode.api"; interface Props { jdkEntries: JavaRuntimeEntry[]; projectRuntimes: ProjectRuntimeEntry[]; + javaDotHome?: string; + toolingJreVersion?: number; + requiredJdkVersion: number; } -export function ProjectJDKPanel({ jdkEntries, projectRuntimes }: Props) { +export function ProjectJDKPanel({ jdkEntries, projectRuntimes, javaDotHome, toolingJreVersion, requiredJdkVersion }: Props) { const [showHintFor, setShowHintFor] = useState<"Maven" | "Gradle" | "Others" | undefined>(); const projectTypeHint = (projectType: ProjectType) => { @@ -72,6 +75,7 @@ export function ProjectJDKPanel({ jdkEntries, projectRuntimes }: Props) { return (

Configure Runtime for Projects

+ {javaDotHome &&

Java Language Server uses {javaDotHome} (Java {toolingJreVersion}). JDK {requiredJdkVersion}+ is required for the language server only; your projects can use older JDKs.

} {projectEntries.length > 0 &&

Manage Java runtime for your projects. If you don't have a valid Java runtime, you can download one.

} { projectEntries.length > 0 ? diff --git a/src/java-runtime/assets/ToolingJDKPanel.tsx b/src/java-runtime/assets/ToolingJDKPanel.tsx index 947ed590..c25f6cde 100644 --- a/src/java-runtime/assets/ToolingJDKPanel.tsx +++ b/src/java-runtime/assets/ToolingJDKPanel.tsx @@ -7,15 +7,14 @@ import { useState } from "react"; import { JavaRuntimeEntry } from "../types"; import { onWillBrowseForJDK, onWillRunCommandFromWebview } from './vscode.api'; -const REQUIRED_JDK_VERSION = 17; - interface Props { jdkEntries?: JavaRuntimeEntry[]; javaDotHome?: string; javaHomeError?: any; + requiredJdkVersion: number; } -export function ToolingJDKPanel({ javaHomeError }: Props) { +export function ToolingJDKPanel({ javaHomeError, requiredJdkVersion }: Props) { const [isDirty, setIsDirty] = useState(false); const onClickBrowseJDKButton = () => { @@ -30,7 +29,7 @@ export function ToolingJDKPanel({ javaHomeError }: Props) { return (

Configure Runtime for Language Server

-
Java Language Server requires a JDK {REQUIRED_JDK_VERSION}+ to launch itself.
+
Java Language Server requires a JDK {requiredJdkVersion}+ to launch itself. Your projects can use older JDKs.
{javaHomeError && (

{javaHomeError}

)} diff --git a/src/java-runtime/assets/index.ts b/src/java-runtime/assets/index.ts index a71ce3d0..c37953ab 100644 --- a/src/java-runtime/assets/index.ts +++ b/src/java-runtime/assets/index.ts @@ -23,17 +23,20 @@ onWillListRuntimes(); function showJavaRuntimeEntries(args: any) { if (args.javaHomeError) { - // TODO: remove after tooling JDK is embedded const props = { jdkEntries: args.javaRuntimes, javaHomeError: args.javaHomeError, - javaDotHome: args.javaDotHome + javaDotHome: args.javaDotHome, + requiredJdkVersion: args.requiredJdkVersion }; root.render(createElement(ToolingJDKPanel, props)); } else { const props = { jdkEntries: args.javaRuntimes, projectRuntimes: args.projectRuntimes, + javaDotHome: args.javaDotHome, + toolingJreVersion: args.toolingJreVersion, + requiredJdkVersion: args.requiredJdkVersion, } root.render(createElement(ProjectJDKPanel, props)); } diff --git a/src/java-runtime/index.ts b/src/java-runtime/index.ts index e3c17e9a..f4e7c8d3 100644 --- a/src/java-runtime/index.ts +++ b/src/java-runtime/index.ts @@ -10,7 +10,7 @@ import { getProjectNameFromUri, getProjectType } from "../utils/jdt"; import { ProjectType } from "../utils/webview"; import { JavaRuntimeEntry, ProjectRuntimeEntry } from "./types"; import { sourceLevelDisplayName } from "./utils/misc"; -import { REQUIRED_JDK_VERSION, resolveRequirements } from "./utils/upstreamApi"; +import { getRequiredJdkVersion, resolveRequirements } from "./utils/upstreamApi"; let javaRuntimeView: vscode.WebviewPanel | undefined; let javaHomes: IJavaRuntime[]; @@ -121,10 +121,14 @@ async function initializeJavaRuntimeView(context: vscode.ExtensionContext, webvi }); if (javaHomeUri) { const javaHome = javaHomeUri[0].fsPath; - if (await getRuntime(javaHome)) { - await vscode.workspace.getConfiguration("java").update("jdt.ls.java.home", javaHome, vscode.ConfigurationTarget.Global); + const runtime = await getRuntime(javaHome, {checkJavac: true, withVersion: true}); + const requiredJdkVersion = getRequiredJdkVersion(); + if (!runtime?.hasJavac) { + await vscode.window.showWarningMessage(`${javaHome} is not a valid JDK home directory.`); + } else if (!runtime.version || runtime.version.major < requiredJdkVersion) { + await vscode.window.showWarningMessage(`Java ${requiredJdkVersion} or more recent is required to launch the Java Language Server. "${javaHome}" doesn't meet the requirement.`); } else { - await vscode.window.showWarningMessage(`${javaHome} is not a valid Java runtime home directory.`); + await vscode.workspace.getConfiguration("java").update("jdt.ls.java.home", javaHome, vscode.ConfigurationTarget.Global); } } break; @@ -198,15 +202,11 @@ export class JavaRuntimeViewSerializer implements vscode.WebviewPanelSerializer } export async function validateJavaRuntime() { - // TODO: - // option a) should check Java LS exported API for java_home - // * option b) use the same way to check java_home as vscode-java try { - const runtime = await resolveRequirements(); - if (runtime.tooling_jre_version >= REQUIRED_JDK_VERSION && runtime.tooling_jre) { - return true; - } + await resolveRequirements(); + return true; } catch (error) { + console.warn(error); } return false; @@ -215,9 +215,12 @@ export async function validateJavaRuntime() { export async function findJavaRuntimeEntries(): Promise<{ javaRuntimes?: JavaRuntimeEntry[], projectRuntimes?: ProjectRuntimeEntry[], + requiredJdkVersion: number; javaDotHome?: string; + toolingJreVersion?: number; javaHomeError?: string; }> { + const requiredJdkVersion = getRequiredJdkVersion(); if (!javaHomes) { const runtimes: IJavaRuntime[] = await findRuntimes({ checkJavac: true, withVersion: true }); javaHomes = runtimes.filter(r => r.hasJavac); @@ -230,14 +233,12 @@ export async function findJavaRuntimeEntries(): Promise<{ })).sort((a, b) => b.majorVersion - a.majorVersion); let javaDotHome; + let toolingJreVersion; let javaHomeError; try { const runtime = await resolveRequirements(); javaDotHome = runtime.tooling_jre; - const javaVersion = runtime.tooling_jre_version; - if (!javaVersion || javaVersion < REQUIRED_JDK_VERSION) { - javaHomeError = `Java ${REQUIRED_JDK_VERSION} or more recent is required by the Java language support (redhat.java) extension. Preferred JDK "${javaDotHome}" (version ${javaVersion}) doesn't meet the requirement. Please specify or install a recent JDK.`; - } + toolingJreVersion = runtime.tooling_jre_version; } catch (error) { javaHomeError = (error as Error).message; } @@ -250,7 +251,9 @@ export async function findJavaRuntimeEntries(): Promise<{ return { javaRuntimes, projectRuntimes, + requiredJdkVersion, javaDotHome, + toolingJreVersion, javaHomeError }; } diff --git a/src/java-runtime/utils/upstreamApi.ts b/src/java-runtime/utils/upstreamApi.ts index a8ed888d..4fa67805 100644 --- a/src/java-runtime/utils/upstreamApi.ts +++ b/src/java-runtime/utils/upstreamApi.ts @@ -1,8 +1,9 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. -// based on https://github.com/redhat-developer/vscode-java/blob/c4cdbed1190fc705c364179dab525645acf03898/src/requirements.ts +// based on https://github.com/redhat-developer/vscode-java/blob/4e49f187a903b8c7b1ed7277a3b2535691fd59f3/src/requirements.ts +import { compareVersions } from "compare-versions"; import * as fse from "fs-extra"; import { findRuntimes, getRuntime, getSources, IJavaRuntime, JAVAC_FILENAME, JAVA_FILENAME } from 'jdk-utils'; import * as path from "path"; @@ -10,101 +11,115 @@ import * as vscode from "vscode"; import { env, workspace } from 'vscode'; const expandHomeDir = require("expand-home-dir"); -export const REQUIRED_JDK_VERSION = 17; -export async function resolveRequirements(): Promise<{ +export interface RequirementsData { tooling_jre: string | undefined; // Used to launch Java extension. tooling_jre_version: number; java_home: string | undefined; // Used as default project JDK. java_version: number; -}> { - const javaExtPath: string | undefined = vscode.extensions.getExtension("redhat.java")?.extensionPath +} + +export function getRequiredJdkVersion(): number { + const javaExt = vscode.extensions.getExtension("redhat.java"); + // Installation guidance uses the current release's minimum when the extension is absent. + if (!javaExt || compareVersions(javaExt.packageJSON.version, "1.57.0") >= 0) { + return 25; + } + if (compareVersions(javaExt.packageJSON.version, "1.39.0") >= 0) { + return 21; + } + return 17; +} + +export async function resolveRequirements(): Promise { + const javaExt = vscode.extensions.getExtension<{ javaRequirement?: RequirementsData }>("redhat.java"); + if (!javaExt) { + throw new Error("The required extension 'redhat.java' is not installed."); + } + const requiredJdkVersion = getRequiredJdkVersion(); + const requirements = javaExt.isActive ? javaExt.exports?.javaRequirement : undefined; + if (requirements) { + if (!requirements.tooling_jre || requirements.tooling_jre_version < requiredJdkVersion) { + throw new Error(getJdkRequirementError(requiredJdkVersion)); + } + return requirements; + } + + const javaExtPath = javaExt.extensionPath; let toolingJre: string | undefined = await findEmbeddedJRE(javaExtPath); let toolingJreVersion: number = await getMajorVersion(toolingJre); - return new Promise(async (resolve, reject) => { - let source: string; - const javaPreferences = checkJavaPreferences(); - let preferenceName = javaPreferences.preference; - let javaVersion: number = 0; - let javaHome = javaPreferences.javaHome; - if (javaHome) { // java.jdt.ls.java.home or java.home setting has highest priority. - source = `java.home variable defined in ${env.appName} settings`; - javaHome = expandHomeDir(javaHome); - if (!await fse.pathExists(javaHome!)) { - invalidJavaHome(reject, `The ${source} points to a missing or inaccessible folder (${javaHome})`); - } else if (!await fse.pathExists(path.resolve(javaHome!, 'bin', JAVAC_FILENAME))) { - let msg: string; - if (await fse.pathExists(path.resolve(javaHome!, JAVAC_FILENAME))) { - msg = `'bin' should be removed from the ${source} (${javaHome})`; - } else { - msg = `The ${source} (${javaHome}) does not point to a JDK.`; - } - invalidJavaHome(reject, msg); + if (toolingJreVersion < requiredJdkVersion) { + toolingJre = undefined; + toolingJreVersion = 0; + } + + const javaPreferences = checkJavaPreferences(); + const preferenceName = javaPreferences.preference; + let javaVersion = 0; + let javaHome = javaPreferences.javaHome; + if (javaHome) { + const source = `${preferenceName} variable defined in ${env.appName} settings`; + javaHome = expandHomeDir(javaHome); + if (!await fse.pathExists(javaHome!)) { + throw new Error(`The ${source} points to a missing or inaccessible folder (${javaHome})`); + } else if (!await fse.pathExists(path.resolve(javaHome!, 'bin', JAVAC_FILENAME))) { + if (await fse.pathExists(path.resolve(javaHome!, JAVAC_FILENAME))) { + throw new Error(`'bin' should be removed from the ${source} (${javaHome})`); } - javaVersion = await getMajorVersion(javaHome); - if (preferenceName === "java.jdt.ls.java.home" || !toolingJre) { + throw new Error(`The ${source} (${javaHome}) does not point to a JDK.`); + } + javaVersion = await getMajorVersion(javaHome); + if (preferenceName === "java.jdt.ls.java.home" || !toolingJre) { + if (javaVersion >= requiredJdkVersion) { toolingJre = javaHome; toolingJreVersion = javaVersion; + } else { + console.warn(`The Java runtime set by '${preferenceName}' does not meet the minimum required version of '${requiredJdkVersion}' and will not be used to launch the Java Language Server.`); } } + } - // java.home not specified, search valid JDKs from env.JAVA_HOME, env.PATH, SDKMAN, jEnv, jabba, Common directories + if (!toolingJre) { const javaRuntimes = await findRuntimes({checkJavac: true, withVersion: true, withTags: true}); - if (!toolingJre) { // universal version - // as latest version as possible. - sortJdksByVersion(javaRuntimes); - const validJdks = javaRuntimes.filter(r => r.version && r.version.major >= REQUIRED_JDK_VERSION); - if (validJdks.length > 0) { - sortJdksBySource(validJdks); - javaHome = validJdks[0].homedir; - javaVersion = validJdks[0].version?.major ?? 0; - toolingJre = javaHome; - toolingJreVersion = javaVersion; + const validJdks: IJavaRuntime[] = []; + for (const runtime of javaRuntimes) { + if (runtime.version && runtime.version.major >= requiredJdkVersion && + (await fse.pathExists(path.join(runtime.homedir, "lib", "rt.jar")) || + await fse.pathExists(path.join(runtime.homedir, "jre", "lib", "rt.jar")) || + await fse.pathExists(path.join(runtime.homedir, "lib", "jrt-fs.jar")))) { + validJdks.push(runtime); } - } else { // pick a default project JDK/JRE - /** - * For legacy users, we implicitly following the order below to - * set a default project JDK during initialization: - * java.home > env.JDK_HOME > env.JAVA_HOME > env.PATH - * - * We'll keep it for compatibility. - */ - if (javaHome && (await getRuntime(javaHome) !== undefined)) { - const runtime = await getRuntime(javaHome, {withVersion: true}); - if (runtime) { - javaHome = runtime.homedir; - javaVersion = runtime.version?.major ?? 0; - } - } else if (javaRuntimes.length) { - sortJdksBySource(javaRuntimes); - javaHome = javaRuntimes[0].homedir; - javaVersion = javaRuntimes[0].version?.major ?? 0; - } else if (javaHome = (await findDefaultRuntimeFromSettings() ?? "")) { - javaVersion = await getMajorVersion(javaHome); - } else { - /** - * Originally it was: - * invalidJavaHome(reject, "Please download and install a JDK to compile your project. You can configure your projects with different JDKs by the setting ['java.configuration.runtimes'](https://github.com/redhat-developer/vscode-java/wiki/JDK-Requirements#java.configuration.runtimes)"); - * - * here we focus on tooling jre, so we swallow the error. - * - */ + } + sortJdksByVersion(validJdks); + sortJdksBySource(validJdks); + if (validJdks.length > 0) { + toolingJre = validJdks[0].homedir; + toolingJreVersion = validJdks[0].version?.major ?? 0; + if (!javaHome) { + javaHome = toolingJre; + javaVersion = toolingJreVersion; } } - - - if (!toolingJre || toolingJreVersion < REQUIRED_JDK_VERSION) { - // For universal version, we still require users to install a qualified JDK to run Java extension. - invalidJavaHome(reject, `Java ${REQUIRED_JDK_VERSION} or more recent is required to run the Java extension. Please download and install a recent JDK. You can still compile your projects with older JDKs by configuring ['java.configuration.runtimes'](https://github.com/redhat-developer/vscode-java/wiki/JDK-Requirements#java.configuration.runtimes)`); + } else if (!javaHome) { + javaHome = await findDefaultRuntimeFromSettings(); + if (javaHome) { + javaVersion = await getMajorVersion(javaHome); + } else { + javaHome = toolingJre; + javaVersion = toolingJreVersion; } + } + + if (!toolingJre || toolingJreVersion < requiredJdkVersion) { + throw new Error(getJdkRequirementError(requiredJdkVersion)); + } - resolve({ - tooling_jre: toolingJre, // Used to launch Java extension. - tooling_jre_version: toolingJreVersion, - java_home: javaHome, // Used as default project JDK. - java_version: javaVersion, - }); - }); + return { + tooling_jre: toolingJre, + tooling_jre_version: toolingJreVersion, + java_home: javaHome, + java_version: javaVersion, + }; } async function findEmbeddedJRE(javaExtPath?: string): Promise { @@ -150,17 +165,20 @@ async function findDefaultRuntimeFromSettings(): Promise { } function sortJdksBySource(jdks: IJavaRuntime[]) { - const rankedJdks = jdks as Array; const sources = ["JDK_HOME", "JAVA_HOME", "PATH"]; - for (const [index, source] of sources.entries()) { - for (const jdk of rankedJdks) { - if (jdk.rank === undefined && getSources(jdk).includes(source)) { - jdk.rank = index; - } + const jdkManagers = ["SDKMAN", "jEnv", "jabba", "asdf"]; + const rank = (jdk: IJavaRuntime) => { + const detectedSources = getSources(jdk); + const sourceIndex = sources.findIndex(source => detectedSources.includes(source)); + if (sourceIndex >= 0) { + return sourceIndex; } - } - rankedJdks.filter(jdk => jdk.rank === undefined).forEach(jdk => jdk.rank = sources.length); - rankedJdks.sort((a, b) => a.rank - b.rank); + if (detectedSources.some(source => jdkManagers.includes(source))) { + return sources.length + 1; + } + return detectedSources.length === 0 ? sources.length + 2 : sources.length + 3; + }; + jdks.sort((a, b) => rank(a) - rank(b)); } /** @@ -184,8 +202,8 @@ function checkJavaPreferences(){ }; } -function invalidJavaHome(reject: any, reason: string) { - reject(new Error(reason)); +function getJdkRequirementError(requiredJdkVersion: number): string { + return `Java ${requiredJdkVersion} or more recent is required to run the Java extension. Please download and install a recent JDK. You can still compile your projects with older JDKs by configuring ['java.configuration.runtimes'](https://github.com/redhat-developer/vscode-java/wiki/JDK-Requirements#java.configuration.runtimes)`; } async function getMajorVersion(javaHome?: string): Promise { diff --git a/tests/java-runtime.test.js b/tests/java-runtime.test.js new file mode 100644 index 00000000..1addb586 --- /dev/null +++ b/tests/java-runtime.test.js @@ -0,0 +1,375 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); +const ts = require("typescript"); + +function loadSource(relativePath, mocks, warnings) { + const filename = path.join(__dirname, "..", relativePath); + const { outputText } = ts.transpileModule(fs.readFileSync(filename, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2016, + esModuleInterop: true, + }, + fileName: filename, + }); + const module = { exports: {} }; + vm.runInNewContext(outputText, { + module, + exports: module.exports, + require: name => Object.prototype.hasOwnProperty.call(mocks, name) ? mocks[name] : require(name), + console: { warn: warning => warnings.push(warning) }, + }, { filename }); + return module.exports; +} + +function setup(version = "1.57.0") { + const extensionPath = path.join(path.parse(__dirname).root, "extensions", "redhat.java"); + const files = new Set(); + const runtimes = new Map(); + const preferences = {}; + const warnings = []; + const discovered = []; + const calls = { discovery: 0, inspection: 0 }; + const extension = { extensionPath, packageJSON: { version }, isActive: false, exports: {} }; + const javac = process.platform === "win32" ? "javac.exe" : "javac"; + const java = process.platform === "win32" ? "java.exe" : "java"; + const jdkUtils = { + JAVAC_FILENAME: javac, + JAVA_FILENAME: java, + findRuntimes: async () => { + calls.discovery++; + return [...discovered]; + }, + getRuntime: async home => { + calls.inspection++; + return runtimes.get(home); + }, + getSources: runtime => runtime.sources, + }; + const vscode = { + extensions: { + getExtension: id => id === "redhat.java" ? extension : undefined, + }, + env: { appName: "Visual Studio Code" }, + workspace: { getConfiguration: () => ({ get: key => preferences[key] }) }, + commands: { executeCommand: async () => [] }, + }; + const mocks = { + vscode, + "jdk-utils": jdkUtils, + "fs-extra": { + pathExists: async filename => files.has(filename), + existsSync: filename => files.has(filename), + statSync: () => ({ isDirectory: () => true }), + readdirSync: () => ["jdk"], + }, + "expand-home-dir": home => home, + }; + const api = loadSource(path.join("src", "java-runtime", "utils", "upstreamApi.ts"), mocks, warnings); + + function addRuntime(major, { source = [], home, embedded = false, jdk = true, valid = true } = {}) { + home = home || (embedded + ? path.join(extensionPath, "jre", "jdk") + : path.join(path.parse(__dirname).root, "jdks", `jdk-${major}-${runtimes.size}`)); + const runtime = { homedir: home, version: { major }, hasJavac: jdk, sources: source }; + runtimes.set(home, runtime); + files.add(home); + files.add(path.join(home, "bin", java)); + if (jdk) { + files.add(path.join(home, "bin", javac)); + } + if (valid) { + files.add(path.join(home, "lib", "jrt-fs.jar")); + } + if (embedded) { + files.add(path.join(extensionPath, "jre")); + } + return runtime; + } + + function loadRuntimeView() { + return loadSource(path.join("src", "java-runtime", "index.ts"), { + ...mocks, + "../utils": {}, + "../utils/jdt": {}, + "../utils/webview": {}, + "./utils/misc": {}, + "./utils/upstreamApi": api, + }, warnings); + } + + return { api, addRuntime, calls, discovered, extension, files, jdkUtils, loadRuntimeView, preferences, vscode, warnings }; +} + +for (const [version, minimum] of [ + ["1.38.0", 17], + ["1.39.0", 21], + ["1.56.0", 21], + ["1.57.0", 25], + ["1.57.2026093000", 25], + ["1.58.0", 25], +]) { + test(`redhat.java ${version} uses JDK ${minimum} as the tooling minimum`, async () => { + const s = setup(version); + assert.equal(s.api.getRequiredJdkVersion(), minimum); + const jdk = s.addRuntime(minimum); + s.discovered.push(jdk); + const result = await s.api.resolveRequirements(); + assert.equal(result.tooling_jre, jdk.homedir); + assert.equal(result.tooling_jre_version, minimum); + }); +} + +for (let version = 17; version < 25; version++) { + test(`JDK ${version} alone does not satisfy redhat.java 1.57.0`, async () => { + const s = setup(); + s.discovered.push(s.addRuntime(version)); + await assert.rejects(s.api.resolveRequirements(), /Java 25 or more recent is required/); + assert.equal(await s.loadRuntimeView().validateJavaRuntime(), false); + }); + + test(`an explicit JDK ${version} falls back to bundled JDK 25 and remains the project JDK`, async () => { + const s = setup(); + const bundled = s.addRuntime(25, { embedded: true, jdk: false }); + const project = s.addRuntime(version); + s.preferences["java.jdt.ls.java.home"] = project.homedir; + const result = await s.api.resolveRequirements(); + assert.equal(result.tooling_jre, bundled.homedir); + assert.equal(result.tooling_jre_version, 25); + assert.equal(result.java_home, project.homedir); + assert.equal(result.java_version, version); + assert.equal(s.calls.discovery, 0); + assert.match(s.warnings[0], /will not be used to launch/); + }); +} + +for (const version of [25, 27]) { + test(`external JDK ${version} satisfies the tooling requirement`, async () => { + const s = setup(); + const jdk = s.addRuntime(version); + s.discovered.push(jdk); + assert.equal(await s.loadRuntimeView().validateJavaRuntime(), true); + assert.equal((await s.api.resolveRequirements()).tooling_jre_version, version); + }); +} + +test("bundled JDK 25 satisfies readiness without discovering an external JDK", async () => { + const s = setup(); + const bundled = s.addRuntime(25, { embedded: true, jdk: false }); + assert.equal(await s.loadRuntimeView().validateJavaRuntime(), true); + const result = await s.api.resolveRequirements(); + assert.equal(result.tooling_jre, bundled.homedir); + assert.equal(result.java_home, bundled.homedir); + assert.equal(result.java_version, 25); + assert.equal(s.calls.discovery, 0); +}); + +test("an under-minimum bundled runtime falls back to a qualified external JDK", async () => { + const s = setup(); + s.addRuntime(21, { embedded: true }); + const external = s.addRuntime(25, { source: ["JAVA_HOME"] }); + s.discovered.push(external); + assert.equal((await s.api.resolveRequirements()).tooling_jre, external.homedir); +}); + +test("an under-minimum explicit JDK falls back to discovery without replacing the project JDK", async () => { + const s = setup(); + const project = s.addRuntime(21); + const tooling = s.addRuntime(25, { source: ["PATH"] }); + s.preferences["java.jdt.ls.java.home"] = project.homedir; + s.discovered.push(project, tooling); + const result = await s.api.resolveRequirements(); + assert.equal(result.tooling_jre, tooling.homedir); + assert.equal(result.java_home, project.homedir); + assert.equal(result.java_version, 21); +}); + +test("a qualified explicit tooling JDK overrides the bundled runtime", async () => { + const s = setup(); + s.addRuntime(27, { embedded: true }); + const explicit = s.addRuntime(25); + s.preferences["java.jdt.ls.java.home"] = explicit.homedir; + assert.equal((await s.api.resolveRequirements()).tooling_jre, explicit.homedir); +}); + +test("deprecated java.home can select an older project JDK without replacing the bundled tooling runtime", async () => { + const s = setup(); + const bundled = s.addRuntime(25, { embedded: true }); + const project = s.addRuntime(8); + s.preferences["java.home"] = project.homedir; + const result = await s.api.resolveRequirements(); + assert.equal(result.tooling_jre, bundled.homedir); + assert.equal(result.java_home, project.homedir); + assert.equal(result.java_version, 8); +}); + +test("java.jdt.ls.java.home takes precedence over deprecated java.home", async () => { + const s = setup(); + const explicit = s.addRuntime(25); + s.preferences["java.jdt.ls.java.home"] = explicit.homedir; + s.preferences["java.home"] = s.addRuntime(27).homedir; + assert.equal((await s.api.resolveRequirements()).tooling_jre, explicit.homedir); +}); + +test("bundled tooling respects the older default project runtime in java.configuration.runtimes", async () => { + const s = setup(); + const bundled = s.addRuntime(25, { embedded: true }); + const project = s.addRuntime(8); + s.preferences["java.configuration.runtimes"] = [ + { path: s.addRuntime(21).homedir }, + { path: project.homedir, default: true }, + { path: s.addRuntime(27).homedir }, + ]; + s.discovered.push(s.addRuntime(27, { source: ["JAVA_HOME"] })); + const result = await s.api.resolveRequirements(); + assert.equal(result.tooling_jre, bundled.homedir); + assert.equal(result.java_home, project.homedir); + assert.equal(result.java_version, 8); +}); + +test("discovery ranks qualified environment JDKs before managers and common directories", async () => { + const s = setup(); + const environment = s.addRuntime(25, { source: ["JAVA_HOME"] }); + s.discovered.push( + s.addRuntime(24, { source: ["JDK_HOME"] }), + s.addRuntime(27, { source: ["SDKMAN"] }), + s.addRuntime(28), + environment, + ); + assert.equal((await s.api.resolveRequirements()).tooling_jre, environment.homedir); +}); + +test("discovery prefers JDK_HOME, then JAVA_HOME, then PATH", async () => { + const s = setup(); + const preferred = s.addRuntime(25, { source: ["JDK_HOME"] }); + s.discovered.push( + s.addRuntime(27, { source: ["PATH"] }), + s.addRuntime(26, { source: ["JAVA_HOME"] }), + preferred, + ); + assert.equal((await s.api.resolveRequirements()).tooling_jre, preferred.homedir); +}); + +test("discovery prefers the newest JDK within the same source rank", async () => { + const s = setup(); + s.discovered.push(s.addRuntime(25), s.addRuntime(27)); + assert.equal((await s.api.resolveRequirements()).tooling_jre_version, 27); +}); + +test("discovery excludes installations missing the runtime libraries", async () => { + const s = setup(); + const valid = s.addRuntime(25); + s.discovered.push(s.addRuntime(27, { valid: false }), valid); + assert.equal((await s.api.resolveRequirements()).tooling_jre, valid.homedir); +}); + +for (const preference of ["java.jdt.ls.java.home", "java.home"]) { + test(`a missing ${preference} path is rejected without attempting discovery`, async () => { + const s = setup(); + s.preferences[preference] = path.join(path.parse(__dirname).root, "missing-jdk"); + s.discovered.push(s.addRuntime(25)); + await assert.rejects(s.api.resolveRequirements(), error => + error.message.includes(preference) && error.message.includes("missing or inaccessible folder")); + assert.equal(s.calls.discovery, 0); + }); +} + +test("an explicitly configured JRE is rejected as not being a JDK", async () => { + const s = setup(); + s.preferences["java.jdt.ls.java.home"] = s.addRuntime(25, { jdk: false }).homedir; + await assert.rejects(s.api.resolveRequirements(), /does not point to a JDK/); +}); + +test("a configured bin directory is rejected with actionable guidance", async () => { + const s = setup(); + const jdk = s.addRuntime(25); + const bin = path.join(jdk.homedir, "bin"); + s.files.add(bin); + s.preferences["java.jdt.ls.java.home"] = bin; + await assert.rejects(s.api.resolveRequirements(), /'bin' should be removed/); +}); + +test("runtime discovery failures reject instead of leaving requirements unresolved", async () => { + const s = setup(); + s.jdkUtils.findRuntimes = async () => { throw new Error("Discovery failed"); }; + await assert.rejects(s.api.resolveRequirements(), /Discovery failed/); +}); + +test("the active language extension's actual runtime is authoritative", async () => { + const s = setup(); + const actual = { + tooling_jre: s.addRuntime(25).homedir, + tooling_jre_version: 25, + java_home: s.addRuntime(8).homedir, + java_version: 8, + }; + s.extension.isActive = true; + s.extension.exports.javaRequirement = actual; + s.preferences["java.jdt.ls.java.home"] = s.addRuntime(27).homedir; + assert.equal(await s.api.resolveRequirements(), actual); + assert.equal(s.calls.inspection, 0); + assert.equal(s.calls.discovery, 0); +}); + +test("an active extension without the requirements API falls back to runtime resolution", async () => { + const s = setup("1.38.0"); + s.extension.isActive = true; + const jdk = s.addRuntime(17); + s.discovered.push(jdk); + assert.equal((await s.api.resolveRequirements()).tooling_jre, jdk.homedir); +}); + +test("an under-minimum runtime exported by the language extension cannot report readiness", async () => { + const s = setup(); + s.extension.isActive = true; + s.extension.exports.javaRequirement = { + tooling_jre: s.addRuntime(24).homedir, + tooling_jre_version: 24, + }; + s.discovered.push(s.addRuntime(25)); + await assert.rejects(s.api.resolveRequirements(), /Java 25 or more recent is required/); + assert.equal(await s.loadRuntimeView().validateJavaRuntime(), false); + assert.equal(s.calls.discovery, 0); +}); + +test("runtime entries expose the same tooling minimum and selected runtime as readiness", async () => { + const s = setup(); + const bundled = s.addRuntime(25, { embedded: true }); + const project = s.addRuntime(8); + s.discovered.push(project); + s.preferences["java.configuration.runtimes"] = [{ path: project.homedir, default: true }]; + const entries = await s.loadRuntimeView().findJavaRuntimeEntries(); + assert.equal(entries.requiredJdkVersion, 25); + assert.equal(entries.javaDotHome, bundled.homedir); + assert.equal(entries.toolingJreVersion, 25); + assert.equal(entries.javaHomeError, undefined); + assert.equal(entries.javaRuntimes[0].majorVersion, 8); +}); + +test("runtime entries report a tooling error while retaining older project JDK choices", async () => { + const s = setup(); + s.discovered.push(s.addRuntime(17), s.addRuntime(21)); + const entries = await s.loadRuntimeView().findJavaRuntimeEntries(); + assert.equal(entries.requiredJdkVersion, 25); + assert.match(entries.javaHomeError, /Java 25 or more recent is required/); + assert.equal(entries.javaDotHome, undefined); + assert.equal(entries.javaRuntimes.length, 2); +}); + +test("a missing language extension reports an error while preserving installation guidance", async () => { + const s = setup(); + s.vscode.extensions.getExtension = () => undefined; + s.discovered.push(s.addRuntime(25)); + assert.equal(s.api.getRequiredJdkVersion(), 25); + await assert.rejects(s.api.resolveRequirements(), /'redhat.java' is not installed/); + assert.equal(await s.loadRuntimeView().validateJavaRuntime(), false); + const entries = await s.loadRuntimeView().findJavaRuntimeEntries(); + assert.equal(entries.requiredJdkVersion, 25); + assert.match(entries.javaHomeError, /'redhat.java' is not installed/); +}); From 4efce4a6374f7dac9d5f343df9ff8476d4352269 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Thu, 8 Oct 2026 11:17:14 +0800 Subject: [PATCH 02/15] fix: leave language-server runtime selection to redhat.java --- README.md | 2 +- src/beginner-tips/assets/tabs/FaqPanel.tsx | 2 +- src/java-runtime/assets/ProjectJDKPanel.tsx | 4 +- src/java-runtime/assets/ToolingJDKPanel.tsx | 45 +- src/java-runtime/assets/index.ts | 22 +- src/java-runtime/index.ts | 111 ++--- src/java-runtime/types.ts | 14 + src/java-runtime/utils/upstreamApi.ts | 234 ++++------ tests/java-runtime.test.js | 464 ++++++++++++++++---- 9 files changed, 578 insertions(+), 320 deletions(-) diff --git a/README.md b/README.md index d89c94c0..c71860e9 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ By installing Extension Pack for Java, the following extensions are installed: The JDK used to launch the Java language server is separate from the JDK used to compile and run your projects. The tooling minimum depends on the installed Language Support for Java extension: `redhat.java` 1.57.0 and newer requires JDK 25+, versions 1.39.0-1.56.x require JDK 21+, and earlier supported versions require JDK 17+. A compatible bundled runtime satisfies the tooling requirement; Java 27 project support does not require JDK 27 just to launch the language server. -Use **Java: Configure Java Runtime** to inspect the selected language-server runtime and configure project JDKs. An explicitly configured tooling JDK below the minimum is not used to launch the language server; the extension can fall back to a compatible bundled runtime or a discovered local JDK. Older project targets remain supported through `java.configuration.runtimes` and your Maven or Gradle build configuration. +Use **Java: Configure Java Runtime** to inspect the language-server runtime reported by `redhat.java` and configure project JDKs. Before the language extension reports its selection, Java Pack shows that the runtime is not yet determined and lists compatible candidates without choosing one or changing your settings. Use **Refresh** after the language extension starts to see its reported runtime; Java Pack does not activate it just to obtain a runtime. Locating a JDK explicitly saves your choice to `java.jdt.ls.java.home`, but `redhat.java` remains responsible for applying that setting and choosing any fallback runtime. Older project targets remain supported through `java.configuration.runtimes` and your Maven or Gradle build configuration. ## Other Recommendations diff --git a/src/beginner-tips/assets/tabs/FaqPanel.tsx b/src/beginner-tips/assets/tabs/FaqPanel.tsx index feae896f..4b7197da 100644 --- a/src/beginner-tips/assets/tabs/FaqPanel.tsx +++ b/src/beginner-tips/assets/tabs/FaqPanel.tsx @@ -49,7 +49,7 @@ export default function FaqPanel({ requiredJdkVersion }: { requiredJdkVersion: n

Why do I see the JDK errors?

- JDK {requiredJdkVersion}+ is required to run the installed Java language support (redhat.java) extension. A compatible bundled runtime can satisfy this requirement; otherwise, a compatible local JDK is needed. You see this error because we failed to find one. The Configure Java Runtime guide can help you understand how JDK path is searched and provides download links if you need to install one. + JDK {requiredJdkVersion}+ is required to run the installed Java language support (redhat.java) extension. A compatible bundled runtime can satisfy this requirement; otherwise, a compatible local JDK is needed. The Configure Java Runtime guide shows the runtime reported by redhat.java, or compatible candidates if its selection is not yet available. Java Pack does not choose the runtime. The guide also provides download links if you need to install a JDK.

Can I run my Java 8 project with JDK 1.8?

diff --git a/src/java-runtime/assets/ProjectJDKPanel.tsx b/src/java-runtime/assets/ProjectJDKPanel.tsx index bdbd19cf..1237b264 100644 --- a/src/java-runtime/assets/ProjectJDKPanel.tsx +++ b/src/java-runtime/assets/ProjectJDKPanel.tsx @@ -19,7 +19,7 @@ import { onWillListRuntimes, openBuildScript } from "./vscode.api"; interface Props { jdkEntries: JavaRuntimeEntry[]; projectRuntimes: ProjectRuntimeEntry[]; - javaDotHome?: string; + javaDotHome: string; toolingJreVersion?: number; requiredJdkVersion: number; } @@ -75,7 +75,7 @@ export function ProjectJDKPanel({ jdkEntries, projectRuntimes, javaDotHome, tool return (

Configure Runtime for Projects

- {javaDotHome &&

Java Language Server uses {javaDotHome} (Java {toolingJreVersion}). JDK {requiredJdkVersion}+ is required for the language server only; your projects can use older JDKs.

} +

Language server runtime reported by redhat.java: {javaDotHome} (Java {toolingJreVersion}). JDK {requiredJdkVersion}+ is required for the language server only; your projects can use older JDKs.

{projectEntries.length > 0 &&

Manage Java runtime for your projects. If you don't have a valid Java runtime, you can download one.

} { projectEntries.length > 0 ? diff --git a/src/java-runtime/assets/ToolingJDKPanel.tsx b/src/java-runtime/assets/ToolingJDKPanel.tsx index c25f6cde..6d819e1e 100644 --- a/src/java-runtime/assets/ToolingJDKPanel.tsx +++ b/src/java-runtime/assets/ToolingJDKPanel.tsx @@ -2,19 +2,18 @@ // Licensed under the MIT license. import "@vscode-elements/elements/dist/vscode-button/index.js"; +import "@vscode-elements/elements/dist/vscode-table/index.js"; +import "@vscode-elements/elements/dist/vscode-table-header/index.js"; +import "@vscode-elements/elements/dist/vscode-table-header-cell/index.js"; +import "@vscode-elements/elements/dist/vscode-table-body/index.js"; +import "@vscode-elements/elements/dist/vscode-table-row/index.js"; +import "@vscode-elements/elements/dist/vscode-table-cell/index.js"; import { useState } from "react"; -import { JavaRuntimeEntry } from "../types"; -import { onWillBrowseForJDK, onWillRunCommandFromWebview } from './vscode.api'; - -interface Props { - jdkEntries?: JavaRuntimeEntry[]; - javaDotHome?: string; - javaHomeError?: any; - requiredJdkVersion: number; -} +import { ToolingRuntimeInfo } from "../types"; +import { onWillBrowseForJDK, onWillListRuntimes, onWillRunCommandFromWebview } from './vscode.api'; -export function ToolingJDKPanel({ javaHomeError, requiredJdkVersion }: Props) { +export function ToolingJDKPanel({ javaHomeError, javaHomeWarning, javaDotHome, toolingJreVersion, toolingRuntimes, requiredJdkVersion }: ToolingRuntimeInfo) { const [isDirty, setIsDirty] = useState(false); const onClickBrowseJDKButton = () => { @@ -31,7 +30,32 @@ export function ToolingJDKPanel({ javaHomeError, requiredJdkVersion }: Props) {

Configure Runtime for Language Server

Java Language Server requires a JDK {requiredJdkVersion}+ to launch itself. Your projects can use older JDKs.
+ {javaDotHome ? ( +

Language server runtime reported by redhat.java: {javaDotHome} (Java {toolingJreVersion}).

+ ) : ( +

The language server runtime is not yet determined. redhat.java has not reported its selection. The runtimes below are candidates only; Java Pack does not select a runtime.

+ )} {javaHomeError && (

{javaHomeError}

)} + {javaHomeWarning && (

{javaHomeWarning}

)} + + {!javaDotHome && toolingRuntimes.length > 0 && ( + + + Candidate Runtime + Java Version + Source + + + {toolingRuntimes.map(runtime => ( + + {runtime.fspath} + {runtime.majorVersion} + {runtime.type} + + ))} + + + )}
Locate an Existing JDK @@ -40,6 +64,7 @@ export function ToolingJDKPanel({ javaHomeError, requiredJdkVersion }: Props) { + Refresh
); } diff --git a/src/java-runtime/assets/index.ts b/src/java-runtime/assets/index.ts index c37953ab..623fa76c 100644 --- a/src/java-runtime/assets/index.ts +++ b/src/java-runtime/assets/index.ts @@ -7,11 +7,12 @@ import "./style.scss"; import { ProjectJDKPanel } from "./ProjectJDKPanel"; import { onWillListRuntimes } from "./vscode.api"; import { ToolingJDKPanel } from "./ToolingJDKPanel"; +import { JavaRuntimeData } from "../types"; const container = document.getElementById("content")!; const root = createRoot(container); -const onInitialize = (event: any) => { +const onInitialize = (event: MessageEvent<{command: string; args: JavaRuntimeData}>) => { const { data } = event; if (data.command === "showJavaRuntimeEntries") { showJavaRuntimeEntries(data.args); @@ -21,23 +22,16 @@ const onInitialize = (event: any) => { window.addEventListener("message", onInitialize); onWillListRuntimes(); -function showJavaRuntimeEntries(args: any) { - if (args.javaHomeError) { - const props = { - jdkEntries: args.javaRuntimes, - javaHomeError: args.javaHomeError, - javaDotHome: args.javaDotHome, - requiredJdkVersion: args.requiredJdkVersion - }; - root.render(createElement(ToolingJDKPanel, props)); +function showJavaRuntimeEntries(args: JavaRuntimeData) { + if (args.javaHomeError || !args.javaDotHome) { + root.render(createElement(ToolingJDKPanel, args)); } else { - const props = { + root.render(createElement(ProjectJDKPanel, { jdkEntries: args.javaRuntimes, projectRuntimes: args.projectRuntimes, javaDotHome: args.javaDotHome, toolingJreVersion: args.toolingJreVersion, - requiredJdkVersion: args.requiredJdkVersion, - } - root.render(createElement(ProjectJDKPanel, props)); + requiredJdkVersion: args.requiredJdkVersion + })); } } diff --git a/src/java-runtime/index.ts b/src/java-runtime/index.ts index f4e7c8d3..7739afc3 100644 --- a/src/java-runtime/index.ts +++ b/src/java-runtime/index.ts @@ -8,9 +8,9 @@ import * as vscode from "vscode"; import { getExtensionContext, getNonce } from "../utils"; import { getProjectNameFromUri, getProjectType } from "../utils/jdt"; import { ProjectType } from "../utils/webview"; -import { JavaRuntimeEntry, ProjectRuntimeEntry } from "./types"; +import { JavaRuntimeData, JavaRuntimeEntry, ProjectRuntimeEntry } from "./types"; import { sourceLevelDisplayName } from "./utils/misc"; -import { getRequiredJdkVersion, resolveRequirements } from "./utils/upstreamApi"; +import { getRequiredJdkVersion, getToolingRuntimeInfo } from "./utils/upstreamApi"; let javaRuntimeView: vscode.WebviewPanel | undefined; let javaHomes: IJavaRuntime[]; @@ -47,9 +47,7 @@ async function initializeJavaRuntimeView(context: vscode.ExtensionContext, webvi context.subscriptions.push(webviewPanel.webview.onDidReceiveMessage(async (e) => { switch (e.command) { case "onWillListRuntimes": { - findJavaRuntimeEntries().then(data => { - showJavaRuntimeEntries(data); - }); + await refreshRuntimes(); break; } case "updateJavaHome": { @@ -70,9 +68,7 @@ async function initializeJavaRuntimeView(context: vscode.ExtensionContext, webvi }); } await vscode.workspace.getConfiguration("java").update("configuration.runtimes", runtimes, vscode.ConfigurationTarget.Global); - findJavaRuntimeEntries().then(data => { - showJavaRuntimeEntries(data); - }); + await refreshRuntimes(); break; } case "setDefaultRuntime": { @@ -100,9 +96,7 @@ async function initializeJavaRuntimeView(context: vscode.ExtensionContext, webvi } } await vscode.workspace.getConfiguration("java").update("configuration.runtimes", runtimes, vscode.ConfigurationTarget.Global); - findJavaRuntimeEntries().then(data => { - showJavaRuntimeEntries(data); - }); + await refreshRuntimes(); break; } case "openBuildScript": { @@ -129,6 +123,7 @@ async function initializeJavaRuntimeView(context: vscode.ExtensionContext, webvi await vscode.window.showWarningMessage(`Java ${requiredJdkVersion} or more recent is required to launch the Java Language Server. "${javaHome}" doesn't meet the requirement.`); } else { await vscode.workspace.getConfiguration("java").update("jdt.ls.java.home", javaHome, vscode.ConfigurationTarget.Global); + await refreshRuntimes(); } } break; @@ -143,24 +138,43 @@ async function initializeJavaRuntimeView(context: vscode.ExtensionContext, webvi } })); - function showJavaRuntimeEntries(args: any) { - webviewPanel.webview.postMessage({ - command: "showJavaRuntimeEntries", - args: args, - }); + let disposed = false; + async function refreshRuntimes() { + try { + const args = await findJavaRuntimeEntries(); + if (!disposed) { + await webviewPanel.webview.postMessage({command: "showJavaRuntimeEntries", args}); + } + } catch (error) { + console.warn(error); + if (!disposed) { + await webviewPanel.webview.postMessage({ + command: "showJavaRuntimeEntries", + args: { + requiredJdkVersion: getRequiredJdkVersion(), + javaRuntimes: [], + projectRuntimes: [], + toolingRuntimes: [], + javaHomeError: error instanceof Error ? error.message : String(error) + } satisfies JavaRuntimeData + }); + } + } } - // refresh webview with latest source levels when classpath (project info) changes + let classpathListener: vscode.Disposable | undefined; const javaExt = vscode.extensions.getExtension("redhat.java"); - if (javaExt?.isActive && javaExt?.exports?.onDidClasspathUpdate) { + if (javaExt?.isActive && javaExt.exports?.onDidClasspathUpdate) { const onDidClasspathUpdate: vscode.Event = javaExt.exports.onDidClasspathUpdate; - const listener = onDidClasspathUpdate((_e: vscode.Uri) => { - findJavaRuntimeEntries().then(data => { - showJavaRuntimeEntries(data); - }); + classpathListener = onDidClasspathUpdate(() => { + void refreshRuntimes(); }); - context.subscriptions.push(webviewPanel.onDidDispose(() => listener.dispose())); } + + context.subscriptions.push(webviewPanel.onDidDispose(() => { + disposed = true; + classpathListener?.dispose(); + })); } function getHtmlForWebview(webviewPanel: vscode.WebviewPanel, scriptPath: string) { @@ -203,8 +217,12 @@ export class JavaRuntimeViewSerializer implements vscode.WebviewPanelSerializer export async function validateJavaRuntime() { try { - await resolveRequirements(); - return true; + const info = await getToolingRuntimeInfo(); + if (!info.javaHomeError) { + // Before activation, this checks availability, not the selected runtime. + return !!info.javaDotHome || info.toolingRuntimes.length > 0; + } + console.warn(info.javaHomeError); } catch (error) { console.warn(error); } @@ -212,18 +230,21 @@ export async function validateJavaRuntime() { return false; } -export async function findJavaRuntimeEntries(): Promise<{ - javaRuntimes?: JavaRuntimeEntry[], - projectRuntimes?: ProjectRuntimeEntry[], - requiredJdkVersion: number; - javaDotHome?: string; - toolingJreVersion?: number; - javaHomeError?: string; -}> { - const requiredJdkVersion = getRequiredJdkVersion(); - if (!javaHomes) { - const runtimes: IJavaRuntime[] = await findRuntimes({ checkJavac: true, withVersion: true }); - javaHomes = runtimes.filter(r => r.hasJavac); +export async function findJavaRuntimeEntries(): Promise { + try { + if (!javaHomes) { + const runtimes: IJavaRuntime[] = await findRuntimes({ checkJavac: true, withVersion: true }); + javaHomes = runtimes.filter(r => r.hasJavac); + } + } catch (error) { + console.warn(error); + const toolingInfo = await getToolingRuntimeInfo([]); + return { + ...toolingInfo, + javaRuntimes: [], + projectRuntimes: [], + javaHomeError: error instanceof Error ? error.message : String(error) + }; } const javaRuntimes: JavaRuntimeEntry[] = javaHomes.map(elem => ({ name: elem.homedir, @@ -232,16 +253,7 @@ export async function findJavaRuntimeEntries(): Promise<{ type: "from jdk-utils" })).sort((a, b) => b.majorVersion - a.majorVersion); - let javaDotHome; - let toolingJreVersion; - let javaHomeError; - try { - const runtime = await resolveRequirements(); - javaDotHome = runtime.tooling_jre; - toolingJreVersion = runtime.tooling_jre_version; - } catch (error) { - javaHomeError = (error as Error).message; - } + const toolingInfo = await getToolingRuntimeInfo(javaHomes); let projectRuntimes = await getProjectRuntimesFromPM(); if (_.isEmpty(projectRuntimes)) { @@ -251,10 +263,7 @@ export async function findJavaRuntimeEntries(): Promise<{ return { javaRuntimes, projectRuntimes, - requiredJdkVersion, - javaDotHome, - toolingJreVersion, - javaHomeError + ...toolingInfo }; } diff --git a/src/java-runtime/types.ts b/src/java-runtime/types.ts index 0ae5bd32..d1a2df9c 100644 --- a/src/java-runtime/types.ts +++ b/src/java-runtime/types.ts @@ -25,3 +25,17 @@ export interface ProjectRuntimeEntry { sourceLevel: string; projectType: ProjectType; } + +export interface ToolingRuntimeInfo { + requiredJdkVersion: number; + toolingRuntimes: JavaRuntimeEntry[]; + javaDotHome?: string; + toolingJreVersion?: number; + javaHomeError?: string; + javaHomeWarning?: string; +} + +export interface JavaRuntimeData extends ToolingRuntimeInfo { + javaRuntimes: JavaRuntimeEntry[]; + projectRuntimes: ProjectRuntimeEntry[]; +} diff --git a/src/java-runtime/utils/upstreamApi.ts b/src/java-runtime/utils/upstreamApi.ts index 4fa67805..dde71a30 100644 --- a/src/java-runtime/utils/upstreamApi.ts +++ b/src/java-runtime/utils/upstreamApi.ts @@ -1,14 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. -// based on https://github.com/redhat-developer/vscode-java/blob/4e49f187a903b8c7b1ed7277a3b2535691fd59f3/src/requirements.ts - import { compareVersions } from "compare-versions"; import * as fse from "fs-extra"; -import { findRuntimes, getRuntime, getSources, IJavaRuntime, JAVAC_FILENAME, JAVA_FILENAME } from 'jdk-utils'; +import { findRuntimes, getRuntime, IJavaRuntime, JAVA_FILENAME } from 'jdk-utils'; import * as path from "path"; import * as vscode from "vscode"; -import { env, workspace } from 'vscode'; +import { JavaRuntimeEntry, ToolingRuntimeInfo } from "../types"; const expandHomeDir = require("expand-home-dir"); @@ -31,185 +29,101 @@ export function getRequiredJdkVersion(): number { return 17; } -export async function resolveRequirements(): Promise { +export async function getToolingRuntimeInfo(discoveredRuntimes?: IJavaRuntime[]): Promise { + const requiredJdkVersion = getRequiredJdkVersion(); + const info: ToolingRuntimeInfo = { requiredJdkVersion, toolingRuntimes: [] }; const javaExt = vscode.extensions.getExtension<{ javaRequirement?: RequirementsData }>("redhat.java"); if (!javaExt) { - throw new Error("The required extension 'redhat.java' is not installed."); + info.javaHomeError = "The required extension 'redhat.java' is not installed."; + return info; } - const requiredJdkVersion = getRequiredJdkVersion(); - const requirements = javaExt.isActive ? javaExt.exports?.javaRequirement : undefined; - if (requirements) { - if (!requirements.tooling_jre || requirements.tooling_jre_version < requiredJdkVersion) { - throw new Error(getJdkRequirementError(requiredJdkVersion)); + applyReportedRuntime(info); + const preferences = vscode.workspace.getConfiguration(); + const toolingHome = preferences.get("java.jdt.ls.java.home"); + const preferenceName = toolingHome ? "java.jdt.ls.java.home" : "java.home"; + const configuredHome = toolingHome || preferences.get("java.home"); + let configuredRuntime: IJavaRuntime | undefined; + if (configuredHome) { + try { + configuredRuntime = await getRuntime(expandHomeDir(configuredHome), {checkJavac: true, withVersion: true}); + } catch (error) { + console.warn(error); + const message = error instanceof Error ? error.message : String(error); + info.javaHomeWarning = `Unable to inspect the configured '${preferenceName}' (${configuredHome}): ${message}. Java Pack has not changed this setting.`; } - return requirements; } - - const javaExtPath = javaExt.extensionPath; - let toolingJre: string | undefined = await findEmbeddedJRE(javaExtPath); - let toolingJreVersion: number = await getMajorVersion(toolingJre); - if (toolingJreVersion < requiredJdkVersion) { - toolingJre = undefined; - toolingJreVersion = 0; + if (!info.javaHomeWarning && configuredHome && !configuredRuntime?.hasJavac) { + info.javaHomeWarning = `The configured '${preferenceName}' (${configuredHome}) does not point to a valid JDK. Java Pack has not changed this setting.`; + } else if (toolingHome && configuredRuntime && (configuredRuntime.version?.major ?? 0) < requiredJdkVersion) { + info.javaHomeWarning = `The configured '${preferenceName}' uses Java ${configuredRuntime.version?.major ?? 0}, below the required Java ${requiredJdkVersion}. It cannot launch the language server. Runtime selection is controlled by redhat.java; project JDKs can still be older.`; } - const javaPreferences = checkJavaPreferences(); - const preferenceName = javaPreferences.preference; - let javaVersion = 0; - let javaHome = javaPreferences.javaHome; - if (javaHome) { - const source = `${preferenceName} variable defined in ${env.appName} settings`; - javaHome = expandHomeDir(javaHome); - if (!await fse.pathExists(javaHome!)) { - throw new Error(`The ${source} points to a missing or inaccessible folder (${javaHome})`); - } else if (!await fse.pathExists(path.resolve(javaHome!, 'bin', JAVAC_FILENAME))) { - if (await fse.pathExists(path.resolve(javaHome!, JAVAC_FILENAME))) { - throw new Error(`'bin' should be removed from the ${source} (${javaHome})`); - } - throw new Error(`The ${source} (${javaHome}) does not point to a JDK.`); - } - javaVersion = await getMajorVersion(javaHome); - if (preferenceName === "java.jdt.ls.java.home" || !toolingJre) { - if (javaVersion >= requiredJdkVersion) { - toolingJre = javaHome; - toolingJreVersion = javaVersion; - } else { - console.warn(`The Java runtime set by '${preferenceName}' does not meet the minimum required version of '${requiredJdkVersion}' and will not be used to launch the Java Language Server.`); - } - } + if (applyReportedRuntime(info)) { + return info; } - if (!toolingJre) { - const javaRuntimes = await findRuntimes({checkJavac: true, withVersion: true, withTags: true}); - const validJdks: IJavaRuntime[] = []; - for (const runtime of javaRuntimes) { - if (runtime.version && runtime.version.major >= requiredJdkVersion && - (await fse.pathExists(path.join(runtime.homedir, "lib", "rt.jar")) || - await fse.pathExists(path.join(runtime.homedir, "jre", "lib", "rt.jar")) || - await fse.pathExists(path.join(runtime.homedir, "lib", "jrt-fs.jar")))) { - validJdks.push(runtime); - } - } - sortJdksByVersion(validJdks); - sortJdksBySource(validJdks); - if (validJdks.length > 0) { - toolingJre = validJdks[0].homedir; - toolingJreVersion = validJdks[0].version?.major ?? 0; - if (!javaHome) { - javaHome = toolingJre; - javaVersion = toolingJreVersion; - } + const candidates = new Map(); + const addCandidate = (runtime: IJavaRuntime, type: string) => { + if (runtime.version && runtime.version.major >= requiredJdkVersion && !candidates.has(runtime.homedir)) { + candidates.set(runtime.homedir, { + name: runtime.homedir, + fspath: runtime.homedir, + majorVersion: runtime.version.major, + type + }); } - } else if (!javaHome) { - javaHome = await findDefaultRuntimeFromSettings(); - if (javaHome) { - javaVersion = await getMajorVersion(javaHome); - } else { - javaHome = toolingJre; - javaVersion = toolingJreVersion; - } - } - - if (!toolingJre || toolingJreVersion < requiredJdkVersion) { - throw new Error(getJdkRequirementError(requiredJdkVersion)); - } - - return { - tooling_jre: toolingJre, - tooling_jre_version: toolingJreVersion, - java_home: javaHome, - java_version: javaVersion, }; -} - -async function findEmbeddedJRE(javaExtPath?: string): Promise { - if (!javaExtPath) { - return undefined; + if (configuredRuntime?.hasJavac) { + addCandidate(configuredRuntime, preferenceName); } - const jreHome = path.join(javaExtPath, "jre"); + + const jreHome = path.join(javaExt.extensionPath, "jre"); if (fse.existsSync(jreHome) && fse.statSync(jreHome).isDirectory()) { - const candidates = fse.readdirSync(jreHome); - for (const candidate of candidates) { - if (fse.existsSync(path.join(jreHome, candidate, "bin", JAVA_FILENAME))) { - return path.join(jreHome, candidate); + for (const candidate of fse.readdirSync(jreHome)) { + const home = path.join(jreHome, candidate); + if (fse.existsSync(path.join(home, "bin", JAVA_FILENAME))) { + const runtime = await getRuntime(home, {withVersion: true}); + if (runtime) { + addCandidate(runtime, "Bundled runtime"); + } } } } - - return; -} - -async function findDefaultRuntimeFromSettings(): Promise { - const runtimes = workspace.getConfiguration().get("java.configuration.runtimes"); - if (Array.isArray(runtimes) && runtimes.length) { - let candidate: string | undefined; - for (const runtime of runtimes) { - if (!runtime || typeof runtime !== 'object' || !runtime.path) { - continue; - } - - const jr = await getRuntime(runtime.path); - if (jr) { - candidate = jr.homedir; - } - - if (runtime.default) { - break; - } + const runtimes = discoveredRuntimes ?? await findRuntimes({checkJavac: true, withVersion: true}); + for (const runtime of runtimes) { + if (runtime.hasJavac && runtime.version && runtime.version.major >= requiredJdkVersion && + (await fse.pathExists(path.join(runtime.homedir, "lib", "rt.jar")) || + await fse.pathExists(path.join(runtime.homedir, "jre", "lib", "rt.jar")) || + await fse.pathExists(path.join(runtime.homedir, "lib", "jrt-fs.jar")))) { + addCandidate(runtime, "Discovered JDK"); } - - return candidate; } - - return undefined; -} - -function sortJdksBySource(jdks: IJavaRuntime[]) { - const sources = ["JDK_HOME", "JAVA_HOME", "PATH"]; - const jdkManagers = ["SDKMAN", "jEnv", "jabba", "asdf"]; - const rank = (jdk: IJavaRuntime) => { - const detectedSources = getSources(jdk); - const sourceIndex = sources.findIndex(source => detectedSources.includes(source)); - if (sourceIndex >= 0) { - return sourceIndex; - } - if (detectedSources.some(source => jdkManagers.includes(source))) { - return sources.length + 1; - } - return detectedSources.length === 0 ? sources.length + 2 : sources.length + 3; - }; - jdks.sort((a, b) => rank(a) - rank(b)); -} - -/** - * Sort by major version in descend order. - */ -function sortJdksByVersion(jdks: IJavaRuntime[]) { - jdks.sort((a, b) => (b.version?.major ?? 0) - (a.version?.major ?? 0)); + // Activation may finish while candidate inspection is in progress. + if (applyReportedRuntime(info)) { + return info; + } + info.toolingRuntimes = [...candidates.values()]; + if (info.toolingRuntimes.length === 0) { + info.javaHomeError = getJdkRequirementError(requiredJdkVersion); + } + return info; } - -function checkJavaPreferences(){ - let preference: string = 'java.jdt.ls.java.home'; - let javaHome = workspace.getConfiguration().get('java.jdt.ls.java.home'); - if (!javaHome) { // Read java.home from the deprecated "java.home" setting. - preference = 'java.home'; - javaHome = workspace.getConfiguration().get('java.home'); +function applyReportedRuntime(info: ToolingRuntimeInfo): boolean { + const javaExt = vscode.extensions.getExtension<{ javaRequirement?: RequirementsData }>("redhat.java"); + const requirements = javaExt?.isActive ? javaExt.exports?.javaRequirement : undefined; + if (!requirements) { + return false; } - return { - javaHome, - preference - }; + info.javaDotHome = requirements.tooling_jre; + info.toolingJreVersion = requirements.tooling_jre_version; + info.toolingRuntimes = []; + info.javaHomeError = !requirements.tooling_jre || requirements.tooling_jre_version < info.requiredJdkVersion + ? getJdkRequirementError(info.requiredJdkVersion) + : undefined; + return true; } function getJdkRequirementError(requiredJdkVersion: number): string { return `Java ${requiredJdkVersion} or more recent is required to run the Java extension. Please download and install a recent JDK. You can still compile your projects with older JDKs by configuring ['java.configuration.runtimes'](https://github.com/redhat-developer/vscode-java/wiki/JDK-Requirements#java.configuration.runtimes)`; } - -async function getMajorVersion(javaHome?: string): Promise { - if (!javaHome) { - return 0; - } - const runtime = await getRuntime(javaHome, { withVersion: true }); - return runtime?.version?.major || 0; -} \ No newline at end of file diff --git a/tests/java-runtime.test.js b/tests/java-runtime.test.js index 1addb586..1f8ac961 100644 --- a/tests/java-runtime.test.js +++ b/tests/java-runtime.test.js @@ -28,6 +28,18 @@ function loadSource(relativePath, mocks, warnings) { return module.exports; } +function createEvent() { + const listeners = new Set(); + return { + subscribe: listener => { + listeners.add(listener); + return { dispose: () => listeners.delete(listener) }; + }, + fire: value => listeners.forEach(listener => listener(value)), + get size() { return listeners.size; }, + }; +} + function setup(version = "1.57.0") { const extensionPath = path.join(path.parse(__dirname).root, "extensions", "redhat.java"); const files = new Set(); @@ -35,8 +47,19 @@ function setup(version = "1.57.0") { const preferences = {}; const warnings = []; const discovered = []; - const calls = { discovery: 0, inspection: 0 }; + const writes = []; + const messages = []; + const disposal = createEvent(); + const configuration = createEvent(); + const classpath = createEvent(); + let receiveMessage; + const calls = { discovery: 0, inspection: 0, activation: 0 }; const extension = { extensionPath, packageJSON: { version }, isActive: false, exports: {} }; + extension.activate = async () => { + calls.activation++; + extension.isActive = true; + return extension.exports; + }; const javac = process.platform === "win32" ? "javac.exe" : "javac"; const java = process.platform === "win32" ? "java.exe" : "java"; const jdkUtils = { @@ -50,14 +73,47 @@ function setup(version = "1.57.0") { calls.inspection++; return runtimes.get(home); }, - getSources: runtime => runtime.sources, + }; + const panel = { + webview: { + cspSource: "test", + asWebviewUri: uri => uri.fsPath, + onDidReceiveMessage: handler => { + receiveMessage = handler; + return { dispose() {} }; + }, + postMessage: async message => { + messages.push(message); + return true; + }, + }, + onDidDispose: disposal.subscribe, + reveal() {}, + dispose: () => disposal.fire(), }; const vscode = { extensions: { getExtension: id => id === "redhat.java" ? extension : undefined, }, - env: { appName: "Visual Studio Code" }, - workspace: { getConfiguration: () => ({ get: key => preferences[key] }) }, + Uri: { file: fsPath => ({ fsPath }) }, + ViewColumn: { One: 1 }, + ConfigurationTarget: { Global: 1 }, + window: { + createWebviewPanel: () => panel, + showOpenDialog: async () => undefined, + showWarningMessage: async message => warnings.push(message), + }, + workspace: { + getConfiguration: section => ({ + get: key => preferences[section ? `${section}.${key}` : key], + update: async (key, value) => { + const setting = section ? `${section}.${key}` : key; + writes.push({ setting, value }); + preferences[setting] = value; + }, + }), + onDidChangeConfiguration: configuration.subscribe, + }, commands: { executeCommand: async () => [] }, }; const mocks = { @@ -96,7 +152,7 @@ function setup(version = "1.57.0") { function loadRuntimeView() { return loadSource(path.join("src", "java-runtime", "index.ts"), { ...mocks, - "../utils": {}, + "../utils": { getNonce: () => "nonce" }, "../utils/jdt": {}, "../utils/webview": {}, "./utils/misc": {}, @@ -104,7 +160,30 @@ function setup(version = "1.57.0") { }, warnings); } - return { api, addRuntime, calls, discovered, extension, files, jdkUtils, loadRuntimeView, preferences, vscode, warnings }; + async function openView() { + const view = loadRuntimeView(); + await view.javaRuntimeCmdHandler({ + extensionPath, + asAbsolutePath: relativePath => path.join(extensionPath, relativePath), + subscriptions: [], + }, "unit-test"); + return view; + } + + return { + api, addRuntime, calls, classpath, configuration, discovered, extension, files, + jdkUtils, loadRuntimeView, messages, openView, panel, preferences, vscode, warnings, writes, + send: (command, payload = {}) => receiveMessage({ command, ...payload }), + }; +} + +function assertUnresolved(info) { + assert.equal(info.javaDotHome, undefined); + assert.equal(info.toolingJreVersion, undefined); +} + +function candidatePaths(info) { + return Array.from(info.toolingRuntimes, runtime => runtime.fspath).sort(); } for (const [version, minimum] of [ @@ -120,9 +199,10 @@ for (const [version, minimum] of [ assert.equal(s.api.getRequiredJdkVersion(), minimum); const jdk = s.addRuntime(minimum); s.discovered.push(jdk); - const result = await s.api.resolveRequirements(); - assert.equal(result.tooling_jre, jdk.homedir); - assert.equal(result.tooling_jre_version, minimum); + const info = await s.api.getToolingRuntimeInfo(); + assertUnresolved(info); + assert.deepEqual(candidatePaths(info), [jdk.homedir]); + assert.equal(info.javaHomeError, undefined); }); } @@ -130,94 +210,109 @@ for (let version = 17; version < 25; version++) { test(`JDK ${version} alone does not satisfy redhat.java 1.57.0`, async () => { const s = setup(); s.discovered.push(s.addRuntime(version)); - await assert.rejects(s.api.resolveRequirements(), /Java 25 or more recent is required/); + const info = await s.api.getToolingRuntimeInfo(); + assertUnresolved(info); + assert.equal(info.toolingRuntimes.length, 0); + assert.match(info.javaHomeError, /Java 25 or more recent is required/); assert.equal(await s.loadRuntimeView().validateJavaRuntime(), false); }); - test(`an explicit JDK ${version} falls back to bundled JDK 25 and remains the project JDK`, async () => { + test(`an explicit JDK ${version} is explained without selecting bundled JDK 25 or changing settings`, async () => { const s = setup(); const bundled = s.addRuntime(25, { embedded: true, jdk: false }); const project = s.addRuntime(version); s.preferences["java.jdt.ls.java.home"] = project.homedir; - const result = await s.api.resolveRequirements(); - assert.equal(result.tooling_jre, bundled.homedir); - assert.equal(result.tooling_jre_version, 25); - assert.equal(result.java_home, project.homedir); - assert.equal(result.java_version, version); - assert.equal(s.calls.discovery, 0); - assert.match(s.warnings[0], /will not be used to launch/); + const info = await s.api.getToolingRuntimeInfo(); + assertUnresolved(info); + assert.deepEqual(candidatePaths(info), [bundled.homedir]); + assert.equal(info.javaHomeError, undefined); + assert.match(info.javaHomeWarning, /below the required Java 25/); + assert.equal(s.preferences["java.jdt.ls.java.home"], project.homedir); + assert.equal(s.writes.length, 0); + assert.equal(s.calls.activation, 0); }); } for (const version of [25, 27]) { - test(`external JDK ${version} satisfies the tooling requirement`, async () => { + test(`external JDK ${version} is an available candidate, not a selected runtime`, async () => { const s = setup(); const jdk = s.addRuntime(version); s.discovered.push(jdk); assert.equal(await s.loadRuntimeView().validateJavaRuntime(), true); - assert.equal((await s.api.resolveRequirements()).tooling_jre_version, version); + const info = await s.api.getToolingRuntimeInfo(); + assertUnresolved(info); + assert.deepEqual(candidatePaths(info), [jdk.homedir]); }); } -test("bundled JDK 25 satisfies readiness without discovering an external JDK", async () => { +test("bundled JDK 25 satisfies availability without a separate JDK 27 installation", async () => { const s = setup(); const bundled = s.addRuntime(25, { embedded: true, jdk: false }); assert.equal(await s.loadRuntimeView().validateJavaRuntime(), true); - const result = await s.api.resolveRequirements(); - assert.equal(result.tooling_jre, bundled.homedir); - assert.equal(result.java_home, bundled.homedir); - assert.equal(result.java_version, 25); - assert.equal(s.calls.discovery, 0); + const info = await s.api.getToolingRuntimeInfo(); + assertUnresolved(info); + assert.deepEqual(candidatePaths(info), [bundled.homedir]); + assert.equal(info.toolingRuntimes[0].type, "Bundled runtime"); + assert.equal(s.writes.length, 0); }); -test("an under-minimum bundled runtime falls back to a qualified external JDK", async () => { +test("an under-minimum bundled runtime is excluded without selecting an external JDK", async () => { const s = setup(); s.addRuntime(21, { embedded: true }); const external = s.addRuntime(25, { source: ["JAVA_HOME"] }); s.discovered.push(external); - assert.equal((await s.api.resolveRequirements()).tooling_jre, external.homedir); + const info = await s.api.getToolingRuntimeInfo(); + assertUnresolved(info); + assert.deepEqual(candidatePaths(info), [external.homedir]); }); -test("an under-minimum explicit JDK falls back to discovery without replacing the project JDK", async () => { +test("an under-minimum explicit JDK does not trigger an independent fallback selection", async () => { const s = setup(); const project = s.addRuntime(21); const tooling = s.addRuntime(25, { source: ["PATH"] }); s.preferences["java.jdt.ls.java.home"] = project.homedir; s.discovered.push(project, tooling); - const result = await s.api.resolveRequirements(); - assert.equal(result.tooling_jre, tooling.homedir); - assert.equal(result.java_home, project.homedir); - assert.equal(result.java_version, 21); + const info = await s.api.getToolingRuntimeInfo(); + assertUnresolved(info); + assert.deepEqual(candidatePaths(info), [tooling.homedir]); + assert.equal(s.preferences["java.jdt.ls.java.home"], project.homedir); }); -test("a qualified explicit tooling JDK overrides the bundled runtime", async () => { +test("a qualified explicit tooling JDK and bundled runtime are both candidates until redhat.java reports a choice", async () => { const s = setup(); - s.addRuntime(27, { embedded: true }); + const bundled = s.addRuntime(27, { embedded: true }); const explicit = s.addRuntime(25); s.preferences["java.jdt.ls.java.home"] = explicit.homedir; - assert.equal((await s.api.resolveRequirements()).tooling_jre, explicit.homedir); + const info = await s.api.getToolingRuntimeInfo(); + assertUnresolved(info); + assert.deepEqual(candidatePaths(info), [bundled.homedir, explicit.homedir].sort()); }); -test("deprecated java.home can select an older project JDK without replacing the bundled tooling runtime", async () => { +test("deprecated java.home remains unchanged and an older project JDK is not a tooling candidate", async () => { const s = setup(); const bundled = s.addRuntime(25, { embedded: true }); const project = s.addRuntime(8); s.preferences["java.home"] = project.homedir; - const result = await s.api.resolveRequirements(); - assert.equal(result.tooling_jre, bundled.homedir); - assert.equal(result.java_home, project.homedir); - assert.equal(result.java_version, 8); + const info = await s.api.getToolingRuntimeInfo(); + assertUnresolved(info); + assert.deepEqual(candidatePaths(info), [bundled.homedir]); + assert.equal(info.javaHomeWarning, undefined); + assert.equal(s.preferences["java.home"], project.homedir); + assert.equal(s.writes.length, 0); }); -test("java.jdt.ls.java.home takes precedence over deprecated java.home", async () => { +test("a configured tooling JDK is inspected without applying it or deprecated java.home", async () => { const s = setup(); const explicit = s.addRuntime(25); s.preferences["java.jdt.ls.java.home"] = explicit.homedir; s.preferences["java.home"] = s.addRuntime(27).homedir; - assert.equal((await s.api.resolveRequirements()).tooling_jre, explicit.homedir); + const info = await s.api.getToolingRuntimeInfo(); + assertUnresolved(info); + assert.deepEqual(candidatePaths(info), [explicit.homedir]); + assert.equal(s.writes.length, 0); }); -test("bundled tooling respects the older default project runtime in java.configuration.runtimes", async () => { +test("listing tooling candidates does not alter the older default project runtime", async () => { const s = setup(); const bundled = s.addRuntime(25, { embedded: true }); const project = s.addRuntime(8); @@ -226,14 +321,17 @@ test("bundled tooling respects the older default project runtime in java.configu { path: project.homedir, default: true }, { path: s.addRuntime(27).homedir }, ]; - s.discovered.push(s.addRuntime(27, { source: ["JAVA_HOME"] })); - const result = await s.api.resolveRequirements(); - assert.equal(result.tooling_jre, bundled.homedir); - assert.equal(result.java_home, project.homedir); - assert.equal(result.java_version, 8); + const external = s.addRuntime(27, { source: ["JAVA_HOME"] }); + s.discovered.push(external); + const before = JSON.stringify(s.preferences); + const info = await s.api.getToolingRuntimeInfo(); + assertUnresolved(info); + assert.deepEqual(candidatePaths(info), [bundled.homedir, external.homedir].sort()); + assert.equal(JSON.stringify(s.preferences), before); + assert.equal(s.writes.length, 0); }); -test("discovery ranks qualified environment JDKs before managers and common directories", async () => { +test("discovery lists every qualified candidate without ranking runtime sources", async () => { const s = setup(); const environment = s.addRuntime(25, { source: ["JAVA_HOME"] }); s.discovered.push( @@ -242,63 +340,79 @@ test("discovery ranks qualified environment JDKs before managers and common dire s.addRuntime(28), environment, ); - assert.equal((await s.api.resolveRequirements()).tooling_jre, environment.homedir); + const info = await s.api.getToolingRuntimeInfo(); + assertUnresolved(info); + assert.equal(info.toolingRuntimes.length, 3); + assert.ok(candidatePaths(info).includes(environment.homedir)); }); -test("discovery prefers JDK_HOME, then JAVA_HOME, then PATH", async () => { +test("JDK_HOME=25 and JAVA_HOME=27 do not cause Java Pack to choose either runtime", async () => { const s = setup(); - const preferred = s.addRuntime(25, { source: ["JDK_HOME"] }); - s.discovered.push( - s.addRuntime(27, { source: ["PATH"] }), - s.addRuntime(26, { source: ["JAVA_HOME"] }), - preferred, - ); - assert.equal((await s.api.resolveRequirements()).tooling_jre, preferred.homedir); + const jdkHome = s.addRuntime(25, { source: ["JDK_HOME"] }); + const javaHome = s.addRuntime(27, { source: ["JAVA_HOME"] }); + s.preferences["java.jdt.ls.java.home"] = s.addRuntime(21).homedir; + for (const discovered of [[jdkHome, javaHome], [javaHome, jdkHome]]) { + s.discovered.splice(0, s.discovered.length, ...discovered); + const info = await s.api.getToolingRuntimeInfo(); + assertUnresolved(info); + assert.deepEqual(candidatePaths(info), [jdkHome.homedir, javaHome.homedir].sort()); + } + assert.equal(s.writes.length, 0); + assert.equal(s.calls.activation, 0); }); -test("discovery prefers the newest JDK within the same source rank", async () => { +test("duplicate detected and configured candidates are listed only once", async () => { const s = setup(); - s.discovered.push(s.addRuntime(25), s.addRuntime(27)); - assert.equal((await s.api.resolveRequirements()).tooling_jre_version, 27); + const jdk = s.addRuntime(25); + s.preferences["java.jdt.ls.java.home"] = jdk.homedir; + s.discovered.push(jdk, jdk); + assert.deepEqual(candidatePaths(await s.api.getToolingRuntimeInfo()), [jdk.homedir]); }); test("discovery excludes installations missing the runtime libraries", async () => { const s = setup(); const valid = s.addRuntime(25); s.discovered.push(s.addRuntime(27, { valid: false }), valid); - assert.equal((await s.api.resolveRequirements()).tooling_jre, valid.homedir); + assert.deepEqual(candidatePaths(await s.api.getToolingRuntimeInfo()), [valid.homedir]); }); for (const preference of ["java.jdt.ls.java.home", "java.home"]) { - test(`a missing ${preference} path is rejected without attempting discovery`, async () => { + test(`a missing ${preference} path is explained without changing settings or inventing a fallback`, async () => { const s = setup(); s.preferences[preference] = path.join(path.parse(__dirname).root, "missing-jdk"); s.discovered.push(s.addRuntime(25)); - await assert.rejects(s.api.resolveRequirements(), error => - error.message.includes(preference) && error.message.includes("missing or inaccessible folder")); - assert.equal(s.calls.discovery, 0); + const info = await s.api.getToolingRuntimeInfo(); + assertUnresolved(info); + assert.ok(info.javaHomeWarning.includes(preference)); + assert.match(info.javaHomeWarning, /does not point to a valid JDK/); + assert.equal(s.writes.length, 0); }); } -test("an explicitly configured JRE is rejected as not being a JDK", async () => { +test("an explicitly configured JRE is not listed as an external tooling JDK", async () => { const s = setup(); s.preferences["java.jdt.ls.java.home"] = s.addRuntime(25, { jdk: false }).homedir; - await assert.rejects(s.api.resolveRequirements(), /does not point to a JDK/); + const info = await s.api.getToolingRuntimeInfo(); + assert.match(info.javaHomeWarning, /does not point to a valid JDK/); + assert.equal(info.toolingRuntimes.length, 0); }); -test("a configured bin directory is rejected with actionable guidance", async () => { +test("an invalid configured bin directory is explained without modifying the setting", async () => { const s = setup(); const jdk = s.addRuntime(25); const bin = path.join(jdk.homedir, "bin"); s.files.add(bin); s.preferences["java.jdt.ls.java.home"] = bin; - await assert.rejects(s.api.resolveRequirements(), /'bin' should be removed/); + const info = await s.api.getToolingRuntimeInfo(); + assert.match(info.javaHomeWarning, /does not point to a valid JDK/); + assert.equal(s.preferences["java.jdt.ls.java.home"], bin); }); test("runtime discovery failures reject instead of leaving requirements unresolved", async () => { const s = setup(); s.jdkUtils.findRuntimes = async () => { throw new Error("Discovery failed"); }; - await assert.rejects(s.api.resolveRequirements(), /Discovery failed/); + await assert.rejects(s.api.getToolingRuntimeInfo(), /Discovery failed/); + assert.equal(await s.loadRuntimeView().validateJavaRuntime(), false); }); test("the active language extension's actual runtime is authoritative", async () => { @@ -312,17 +426,22 @@ test("the active language extension's actual runtime is authoritative", async () s.extension.isActive = true; s.extension.exports.javaRequirement = actual; s.preferences["java.jdt.ls.java.home"] = s.addRuntime(27).homedir; - assert.equal(await s.api.resolveRequirements(), actual); - assert.equal(s.calls.inspection, 0); + const info = await s.api.getToolingRuntimeInfo(); + assert.equal(info.javaDotHome, actual.tooling_jre); + assert.equal(info.toolingJreVersion, 25); + assert.equal(info.toolingRuntimes.length, 0); assert.equal(s.calls.discovery, 0); + assert.equal(s.writes.length, 0); }); -test("an active extension without the requirements API falls back to runtime resolution", async () => { +test("an active extension without the requirements API still reports an undetermined runtime", async () => { const s = setup("1.38.0"); s.extension.isActive = true; const jdk = s.addRuntime(17); s.discovered.push(jdk); - assert.equal((await s.api.resolveRequirements()).tooling_jre, jdk.homedir); + const info = await s.api.getToolingRuntimeInfo(); + assertUnresolved(info); + assert.deepEqual(candidatePaths(info), [jdk.homedir]); }); test("an under-minimum runtime exported by the language extension cannot report readiness", async () => { @@ -333,12 +452,15 @@ test("an under-minimum runtime exported by the language extension cannot report tooling_jre_version: 24, }; s.discovered.push(s.addRuntime(25)); - await assert.rejects(s.api.resolveRequirements(), /Java 25 or more recent is required/); + const info = await s.api.getToolingRuntimeInfo(); + assert.match(info.javaHomeError, /Java 25 or more recent is required/); + assert.equal(info.javaDotHome, s.extension.exports.javaRequirement.tooling_jre); + assert.equal(info.toolingJreVersion, 24); assert.equal(await s.loadRuntimeView().validateJavaRuntime(), false); assert.equal(s.calls.discovery, 0); }); -test("runtime entries expose the same tooling minimum and selected runtime as readiness", async () => { +test("runtime entries separate bundled tooling candidates from older project JDK choices", async () => { const s = setup(); const bundled = s.addRuntime(25, { embedded: true }); const project = s.addRuntime(8); @@ -346,8 +468,8 @@ test("runtime entries expose the same tooling minimum and selected runtime as re s.preferences["java.configuration.runtimes"] = [{ path: project.homedir, default: true }]; const entries = await s.loadRuntimeView().findJavaRuntimeEntries(); assert.equal(entries.requiredJdkVersion, 25); - assert.equal(entries.javaDotHome, bundled.homedir); - assert.equal(entries.toolingJreVersion, 25); + assertUnresolved(entries); + assert.deepEqual(candidatePaths(entries), [bundled.homedir]); assert.equal(entries.javaHomeError, undefined); assert.equal(entries.javaRuntimes[0].majorVersion, 8); }); @@ -367,9 +489,189 @@ test("a missing language extension reports an error while preserving installatio s.vscode.extensions.getExtension = () => undefined; s.discovered.push(s.addRuntime(25)); assert.equal(s.api.getRequiredJdkVersion(), 25); - await assert.rejects(s.api.resolveRequirements(), /'redhat.java' is not installed/); + assert.match((await s.api.getToolingRuntimeInfo()).javaHomeError, /'redhat.java' is not installed/); assert.equal(await s.loadRuntimeView().validateJavaRuntime(), false); const entries = await s.loadRuntimeView().findJavaRuntimeEntries(); assert.equal(entries.requiredJdkVersion, 25); assert.match(entries.javaHomeError, /'redhat.java' is not installed/); }); + +test("manual refresh transitions from candidates to the authoritative JDK 27 without activating or configuring Java", async () => { + const s = setup(); + const jdkHome = s.addRuntime(25, { source: ["JDK_HOME"] }); + const javaHome = s.addRuntime(27, { source: ["JAVA_HOME"] }); + s.discovered.push(jdkHome, javaHome); + await s.openView(); + await s.send("onWillListRuntimes"); + assertUnresolved(s.messages.at(-1).args); + assert.deepEqual(candidatePaths(s.messages.at(-1).args), [jdkHome.homedir, javaHome.homedir].sort()); + assert.equal(s.configuration.size, 0); + + const messagesBeforeActivation = s.messages.length; + s.extension.isActive = true; + s.extension.exports = { + javaRequirement: { tooling_jre: javaHome.homedir, tooling_jre_version: 27 }, + onDidClasspathUpdate: s.classpath.subscribe, + }; + await new Promise(setImmediate); + assert.equal(s.messages.length, messagesBeforeActivation); + await s.send("onWillListRuntimes"); + assert.equal(s.messages.at(-1).args.javaDotHome, javaHome.homedir); + assert.equal(s.messages.at(-1).args.toolingJreVersion, 27); + assert.equal(s.messages.at(-1).args.toolingRuntimes.length, 0); + assert.equal(s.classpath.size, 0); + assert.equal(s.calls.activation, 0); + assert.equal(s.writes.length, 0); + s.panel.dispose(); + assert.equal(s.classpath.size, 0); + assert.equal(s.configuration.size, 0); +}); + +test("a pending runtime view does not add activation or configuration listeners", async () => { + const s = setup(); + await s.openView(); + assert.equal(s.classpath.size, 0); + assert.equal(s.configuration.size, 0); + s.panel.dispose(); + assert.equal(s.configuration.size, 0); + assert.equal(s.calls.activation, 0); +}); + +test("refresh preserves the existing JDK cache but reads the current authoritative runtime", async () => { + const s = setup(); + const jdk = s.addRuntime(25); + s.discovered.push(jdk); + const view = s.loadRuntimeView(); + assertUnresolved(await view.findJavaRuntimeEntries()); + const actual = s.addRuntime(27); + s.extension.isActive = true; + s.extension.exports.javaRequirement = { tooling_jre: actual.homedir, tooling_jre_version: 27 }; + s.discovered.push(actual); + const entries = await view.findJavaRuntimeEntries(); + assert.equal(entries.javaDotHome, actual.homedir); + assert.equal(entries.toolingJreVersion, 27); + assert.equal(entries.javaRuntimes.length, 1); + assert.equal(s.calls.discovery, 1); + assert.equal(entries.javaHomeError, undefined); +}); + +for (const version of [25, 27]) { + test(`only explicitly locating JDK ${version} saves the tooling setting, not the actual runtime`, async () => { + const s = setup(); + const selected = s.addRuntime(version); + const actual = s.addRuntime(27); + s.extension.isActive = true; + s.extension.exports.javaRequirement = { tooling_jre: actual.homedir, tooling_jre_version: 27 }; + s.vscode.window.showOpenDialog = async () => [{ fsPath: selected.homedir }]; + await s.openView(); + assert.equal(s.writes.length, 0); + await s.send("onWillBrowseForJDK"); + assert.deepEqual(s.writes, [{ setting: "java.jdt.ls.java.home", value: selected.homedir }]); + assert.equal(s.messages.at(-1).args.javaDotHome, actual.homedir); + assert.equal(s.calls.activation, 0); + s.panel.dispose(); + }); +} + +for (const [version, jdk] of [[24, true], [25, false]]) { + test(`locating ${jdk ? "an under-minimum JDK" : "a JRE"} does not save the tooling setting`, async () => { + const s = setup(); + const invalid = s.addRuntime(version, { jdk }); + s.vscode.window.showOpenDialog = async () => [{ fsPath: invalid.homedir }]; + await s.openView(); + await s.send("onWillBrowseForJDK"); + assert.equal(s.writes.length, 0); + assert.match(s.warnings.at(-1), jdk ? /Java 25 or more recent is required/ : /not a valid JDK/); + s.panel.dispose(); + }); +} + +test("manual refresh updates configuration warnings without selecting or modifying a runtime", async () => { + const s = setup(); + const jdk = s.addRuntime(25); + s.discovered.push(jdk); + await s.openView(); + s.preferences["java.jdt.ls.java.home"] = s.addRuntime(21).homedir; + assert.equal(s.configuration.size, 0); + await s.send("onWillListRuntimes"); + const entries = s.messages.at(-1).args; + assertUnresolved(entries); + assert.match(entries.javaHomeWarning, /below the required Java 25/); + assert.deepEqual(candidatePaths(entries), [jdk.homedir]); + assert.equal(s.writes.length, 0); + s.panel.dispose(); +}); + +test("candidate-discovery failures are surfaced in the runtime view", async () => { + const s = setup(); + s.jdkUtils.findRuntimes = async () => { throw new Error("Discovery failed"); }; + await s.openView(); + await s.send("onWillListRuntimes"); + assert.match(s.messages.at(-1).args.javaHomeError, /Discovery failed/); + assert.ok(s.warnings.some(warning => warning.message === "Discovery failed")); + s.panel.dispose(); +}); + +test("activation during candidate discovery cannot return a stale guessed runtime", async () => { + const s = setup(); + const jdkHome = s.addRuntime(25, { source: ["JDK_HOME"] }); + const javaHome = s.addRuntime(27, { source: ["JAVA_HOME"] }); + let finishDiscovery; + s.jdkUtils.findRuntimes = () => new Promise(resolve => { finishDiscovery = resolve; }); + const request = s.api.getToolingRuntimeInfo(); + s.extension.isActive = true; + s.extension.exports.javaRequirement = { tooling_jre: javaHome.homedir, tooling_jre_version: 27 }; + finishDiscovery([jdkHome, javaHome]); + const info = await request; + assert.equal(info.javaDotHome, javaHome.homedir); + assert.equal(info.toolingJreVersion, 27); + assert.equal(info.toolingRuntimes.length, 0); +}); + +test("failure to inspect a setting does not hide the authoritative runtime", async () => { + const s = setup(); + const actual = s.addRuntime(27); + s.extension.isActive = true; + s.extension.exports.javaRequirement = { tooling_jre: actual.homedir, tooling_jre_version: 27 }; + s.preferences["java.jdt.ls.java.home"] = s.addRuntime(25).homedir; + s.jdkUtils.getRuntime = async () => { throw new Error("Inspection failed"); }; + const info = await s.api.getToolingRuntimeInfo(); + assert.equal(info.javaDotHome, actual.homedir); + assert.equal(info.javaHomeError, undefined); + assert.match(info.javaHomeWarning, /Inspection failed/); + assert.equal(s.calls.discovery, 0); +}); + +test("failure to discover project JDK choices does not invent or hide the authoritative runtime", async () => { + const s = setup(); + const actual = s.addRuntime(27); + s.extension.isActive = true; + s.extension.exports.javaRequirement = { tooling_jre: actual.homedir, tooling_jre_version: 27 }; + s.jdkUtils.findRuntimes = async () => { throw new Error("Discovery failed"); }; + const entries = await s.loadRuntimeView().findJavaRuntimeEntries(); + assert.equal(entries.javaDotHome, actual.homedir); + assert.equal(entries.toolingJreVersion, 27); + assert.match(entries.javaHomeError, /Discovery failed/); +}); + +test("classpath updates remain observable when the language extension has not reported a runtime", async () => { + const s = setup("1.38.0"); + s.extension.isActive = true; + s.extension.exports.onDidClasspathUpdate = s.classpath.subscribe; + s.discovered.push(s.addRuntime(17)); + await s.openView(); + assert.equal(s.classpath.size, 1); + s.classpath.fire(); + await new Promise(setImmediate); + assertUnresolved(s.messages.at(-1).args); + assert.equal(s.configuration.size, 0); + s.panel.dispose(); + assert.equal(s.classpath.size, 0); +}); + +test("non-Error discovery failures are still visible rather than appearing as successful empty results", async () => { + const s = setup(); + s.jdkUtils.findRuntimes = async () => { throw "Discovery failed"; }; + const entries = await s.loadRuntimeView().findJavaRuntimeEntries(); + assert.equal(entries.javaHomeError, "Discovery failed"); +}); From 61249b07d720454f3228c29e6079cb2751e4b736 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Thu, 8 Oct 2026 13:44:24 +0800 Subject: [PATCH 03/15] fix: limit runtime guidance to upstream-reported information --- README.md | 2 +- src/beginner-tips/assets/tabs/FaqPanel.tsx | 2 +- src/extension.ts | 8 +- src/java-runtime/assets/ProjectJDKPanel.tsx | 18 +- src/java-runtime/assets/ToolingJDKPanel.tsx | 29 +- src/java-runtime/assets/index.ts | 6 +- src/java-runtime/index.ts | 44 +- src/java-runtime/types.ts | 2 +- src/java-runtime/utils/upstreamApi.ts | 113 +-- tests/java-runtime.test.js | 738 ++++++-------------- 10 files changed, 267 insertions(+), 695 deletions(-) diff --git a/README.md b/README.md index c71860e9..27fd5740 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ By installing Extension Pack for Java, the following extensions are installed: The JDK used to launch the Java language server is separate from the JDK used to compile and run your projects. The tooling minimum depends on the installed Language Support for Java extension: `redhat.java` 1.57.0 and newer requires JDK 25+, versions 1.39.0-1.56.x require JDK 21+, and earlier supported versions require JDK 17+. A compatible bundled runtime satisfies the tooling requirement; Java 27 project support does not require JDK 27 just to launch the language server. -Use **Java: Configure Java Runtime** to inspect the language-server runtime reported by `redhat.java` and configure project JDKs. Before the language extension reports its selection, Java Pack shows that the runtime is not yet determined and lists compatible candidates without choosing one or changing your settings. Use **Refresh** after the language extension starts to see its reported runtime; Java Pack does not activate it just to obtain a runtime. Locating a JDK explicitly saves your choice to `java.jdt.ls.java.home`, but `redhat.java` remains responsible for applying that setting and choosing any fallback runtime. Older project targets remain supported through `java.configuration.runtimes` and your Maven or Gradle build configuration. +Use **Java: Configure Java Runtime** to inspect the language-server runtime reported by `redhat.java` and configure project JDKs. If no result is available, Java Pack displays an informational message without treating it as a startup failure or blocking project configuration. Use **Refresh** after the language extension starts, or **Open Java Logs** for details. Runtime selection and startup diagnostics belong to `redhat.java`; Java Pack does not probe tooling candidates, activate the language extension to obtain runtime information, or automatically open runtime configuration based on its own checks. Locating a JDK explicitly saves your choice to `java.jdt.ls.java.home`. Older project targets remain supported through `java.configuration.runtimes` and your Maven or Gradle build configuration. ## Other Recommendations diff --git a/src/beginner-tips/assets/tabs/FaqPanel.tsx b/src/beginner-tips/assets/tabs/FaqPanel.tsx index 4b7197da..d5ab8ef9 100644 --- a/src/beginner-tips/assets/tabs/FaqPanel.tsx +++ b/src/beginner-tips/assets/tabs/FaqPanel.tsx @@ -49,7 +49,7 @@ export default function FaqPanel({ requiredJdkVersion }: { requiredJdkVersion: n

Why do I see the JDK errors?

- JDK {requiredJdkVersion}+ is required to run the installed Java language support (redhat.java) extension. A compatible bundled runtime can satisfy this requirement; otherwise, a compatible local JDK is needed. The Configure Java Runtime guide shows the runtime reported by redhat.java, or compatible candidates if its selection is not yet available. Java Pack does not choose the runtime. The guide also provides download links if you need to install a JDK. + JDK {requiredJdkVersion}+ is required to run the installed Java language support (redhat.java) extension. A compatible bundled runtime can satisfy this requirement; otherwise, a compatible local JDK is needed. The Configure Java Runtime guide shows the runtime reported by redhat.java. If no result is available yet, it displays an informational message rather than diagnosing a startup failure. Runtime selection and startup diagnostics are provided by redhat.java; open the Java logs for details.

Can I run my Java 8 project with JDK 1.8?

diff --git a/src/extension.ts b/src/extension.ts index 43f65356..42f91c08 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -13,7 +13,7 @@ import { JavaExtGuideViewSerializer } from "./ext-guide"; import { initFormatterSettingsEditorProvider } from "./formatter-settings"; import { initRemoteProfileProvider } from "./formatter-settings/RemoteProfileProvider"; import { InstallJdkViewSerializer } from "./install-jdk"; -import { JavaRuntimeViewSerializer, validateJavaRuntime } from "./java-runtime"; +import { JavaRuntimeViewSerializer } from "./java-runtime"; import { HelpViewType, showReleaseNotesOnStart } from "./misc"; import { OverviewViewSerializer } from "./overview"; import { CodeActionProvider } from "./providers/CodeActionProvider"; @@ -76,12 +76,6 @@ async function initializeExtension(_operationId: string, context: vscode.Extensi showReleaseNotesOnStart(context); }); } - - if (!await validateJavaRuntime()) { - scheduleAction("showJdkState", true, true).then(() => { - vscode.commands.executeCommand("java.runtime"); - }); - } } async function presentFirstView(context: vscode.ExtensionContext) { diff --git a/src/java-runtime/assets/ProjectJDKPanel.tsx b/src/java-runtime/assets/ProjectJDKPanel.tsx index 1237b264..16b6b28c 100644 --- a/src/java-runtime/assets/ProjectJDKPanel.tsx +++ b/src/java-runtime/assets/ProjectJDKPanel.tsx @@ -19,12 +19,14 @@ import { onWillListRuntimes, openBuildScript } from "./vscode.api"; interface Props { jdkEntries: JavaRuntimeEntry[]; projectRuntimes: ProjectRuntimeEntry[]; - javaDotHome: string; + javaDotHome?: string; toolingJreVersion?: number; requiredJdkVersion: number; + javaHomeWarning?: string; + projectJdkError?: string; } -export function ProjectJDKPanel({ jdkEntries, projectRuntimes, javaDotHome, toolingJreVersion, requiredJdkVersion }: Props) { +export function ProjectJDKPanel({ jdkEntries, projectRuntimes, javaDotHome, toolingJreVersion, requiredJdkVersion, javaHomeWarning, projectJdkError }: Props) { const [showHintFor, setShowHintFor] = useState<"Maven" | "Gradle" | "Others" | undefined>(); const projectTypeHint = (projectType: ProjectType) => { @@ -71,11 +73,19 @@ export function ProjectJDKPanel({ jdkEntries, projectRuntimes, javaDotHome, tool )); const downloadJDKCommand = encodeCommandUriWithTelemetry("java.runtime", "download", "java.installJdk"); + const toolingSettingsCommand = encodeCommandUriWithTelemetry("java.runtime", "toolingSettings", "workbench.action.openSettings", ["java.jdt.ls.java.home"]); return (

Configure Runtime for Projects

-

Language server runtime reported by redhat.java: {javaDotHome} (Java {toolingJreVersion}). JDK {requiredJdkVersion}+ is required for the language server only; your projects can use older JDKs.

+ {javaDotHome ? ( +

Language server runtime reported by redhat.java: {javaDotHome} (Java {toolingJreVersion}).

+ ) : ( +

Language server runtime information is not available yet. Use Refresh after redhat.java starts, or open the Java logs for details.

+ )} +

JDK {requiredJdkVersion}+ is required for the language server only; your projects can use older JDKs. Open language-server runtime settings.

+ {javaHomeWarning &&

{javaHomeWarning} Open Java Logs.

} + {projectJdkError &&

{projectJdkError}

} {projectEntries.length > 0 &&

Manage Java runtime for your projects. If you don't have a valid Java runtime, you can download one.

} { projectEntries.length > 0 ? @@ -92,9 +102,9 @@ export function ProjectJDKPanel({ jdkEntries, projectRuntimes, javaDotHome, tool :

No project detected yet. Please refresh later if Java extension is importing your projects.

- Refresh
} + Refresh
); diff --git a/src/java-runtime/assets/ToolingJDKPanel.tsx b/src/java-runtime/assets/ToolingJDKPanel.tsx index 6d819e1e..fecb4e03 100644 --- a/src/java-runtime/assets/ToolingJDKPanel.tsx +++ b/src/java-runtime/assets/ToolingJDKPanel.tsx @@ -2,18 +2,12 @@ // Licensed under the MIT license. import "@vscode-elements/elements/dist/vscode-button/index.js"; -import "@vscode-elements/elements/dist/vscode-table/index.js"; -import "@vscode-elements/elements/dist/vscode-table-header/index.js"; -import "@vscode-elements/elements/dist/vscode-table-header-cell/index.js"; -import "@vscode-elements/elements/dist/vscode-table-body/index.js"; -import "@vscode-elements/elements/dist/vscode-table-row/index.js"; -import "@vscode-elements/elements/dist/vscode-table-cell/index.js"; import { useState } from "react"; import { ToolingRuntimeInfo } from "../types"; import { onWillBrowseForJDK, onWillListRuntimes, onWillRunCommandFromWebview } from './vscode.api'; -export function ToolingJDKPanel({ javaHomeError, javaHomeWarning, javaDotHome, toolingJreVersion, toolingRuntimes, requiredJdkVersion }: ToolingRuntimeInfo) { +export function ToolingJDKPanel({ javaHomeError, javaHomeWarning, javaDotHome, toolingJreVersion, requiredJdkVersion }: ToolingRuntimeInfo) { const [isDirty, setIsDirty] = useState(false); const onClickBrowseJDKButton = () => { @@ -33,29 +27,12 @@ export function ToolingJDKPanel({ javaHomeError, javaHomeWarning, javaDotHome, t {javaDotHome ? (

Language server runtime reported by redhat.java: {javaDotHome} (Java {toolingJreVersion}).

) : ( -

The language server runtime is not yet determined. redhat.java has not reported its selection. The runtimes below are candidates only; Java Pack does not select a runtime.

+

Language server runtime information has not been reported by redhat.java.

)} {javaHomeError && (

{javaHomeError}

)} {javaHomeWarning && (

{javaHomeWarning}

)} - {!javaDotHome && toolingRuntimes.length > 0 && ( - - - Candidate Runtime - Java Version - Source - - - {toolingRuntimes.map(runtime => ( - - {runtime.fspath} - {runtime.majorVersion} - {runtime.type} - - ))} - - - )} +

Open Java Logs for details. Runtime selection and startup diagnostics are provided by redhat.java.

Locate an Existing JDK diff --git a/src/java-runtime/assets/index.ts b/src/java-runtime/assets/index.ts index 623fa76c..8b3659ad 100644 --- a/src/java-runtime/assets/index.ts +++ b/src/java-runtime/assets/index.ts @@ -23,7 +23,7 @@ window.addEventListener("message", onInitialize); onWillListRuntimes(); function showJavaRuntimeEntries(args: JavaRuntimeData) { - if (args.javaHomeError || !args.javaDotHome) { + if (args.javaHomeError) { root.render(createElement(ToolingJDKPanel, args)); } else { root.render(createElement(ProjectJDKPanel, { @@ -31,7 +31,9 @@ function showJavaRuntimeEntries(args: JavaRuntimeData) { projectRuntimes: args.projectRuntimes, javaDotHome: args.javaDotHome, toolingJreVersion: args.toolingJreVersion, - requiredJdkVersion: args.requiredJdkVersion + requiredJdkVersion: args.requiredJdkVersion, + javaHomeWarning: args.javaHomeWarning, + projectJdkError: args.projectJdkError })); } } diff --git a/src/java-runtime/index.ts b/src/java-runtime/index.ts index 7739afc3..8776ff8c 100644 --- a/src/java-runtime/index.ts +++ b/src/java-runtime/index.ts @@ -148,16 +148,8 @@ async function initializeJavaRuntimeView(context: vscode.ExtensionContext, webvi } catch (error) { console.warn(error); if (!disposed) { - await webviewPanel.webview.postMessage({ - command: "showJavaRuntimeEntries", - args: { - requiredJdkVersion: getRequiredJdkVersion(), - javaRuntimes: [], - projectRuntimes: [], - toolingRuntimes: [], - javaHomeError: error instanceof Error ? error.message : String(error) - } satisfies JavaRuntimeData - }); + const message = error instanceof Error ? error.message : String(error); + await vscode.window.showErrorMessage(`Unable to refresh Configure Java Runtime: ${message}`); } } } @@ -215,22 +207,8 @@ export class JavaRuntimeViewSerializer implements vscode.WebviewPanelSerializer } } -export async function validateJavaRuntime() { - try { - const info = await getToolingRuntimeInfo(); - if (!info.javaHomeError) { - // Before activation, this checks availability, not the selected runtime. - return !!info.javaDotHome || info.toolingRuntimes.length > 0; - } - console.warn(info.javaHomeError); - } catch (error) { - console.warn(error); - } - - return false; -} - export async function findJavaRuntimeEntries(): Promise { + let projectJdkError: string | undefined; try { if (!javaHomes) { const runtimes: IJavaRuntime[] = await findRuntimes({ checkJavac: true, withVersion: true }); @@ -238,23 +216,16 @@ export async function findJavaRuntimeEntries(): Promise { } } catch (error) { console.warn(error); - const toolingInfo = await getToolingRuntimeInfo([]); - return { - ...toolingInfo, - javaRuntimes: [], - projectRuntimes: [], - javaHomeError: error instanceof Error ? error.message : String(error) - }; + const message = error instanceof Error ? error.message : String(error); + projectJdkError = `Unable to list installed project JDKs: ${message}`; } - const javaRuntimes: JavaRuntimeEntry[] = javaHomes.map(elem => ({ + const javaRuntimes: JavaRuntimeEntry[] = (javaHomes ?? []).map(elem => ({ name: elem.homedir, fspath: elem.homedir, majorVersion: elem.version?.major || 0, type: "from jdk-utils" })).sort((a, b) => b.majorVersion - a.majorVersion); - const toolingInfo = await getToolingRuntimeInfo(javaHomes); - let projectRuntimes = await getProjectRuntimesFromPM(); if (_.isEmpty(projectRuntimes)) { projectRuntimes = await getProjectRuntimesFromLS(); @@ -263,7 +234,8 @@ export async function findJavaRuntimeEntries(): Promise { return { javaRuntimes, projectRuntimes, - ...toolingInfo + projectJdkError, + ...getToolingRuntimeInfo() }; } diff --git a/src/java-runtime/types.ts b/src/java-runtime/types.ts index d1a2df9c..281ea834 100644 --- a/src/java-runtime/types.ts +++ b/src/java-runtime/types.ts @@ -28,7 +28,6 @@ export interface ProjectRuntimeEntry { export interface ToolingRuntimeInfo { requiredJdkVersion: number; - toolingRuntimes: JavaRuntimeEntry[]; javaDotHome?: string; toolingJreVersion?: number; javaHomeError?: string; @@ -38,4 +37,5 @@ export interface ToolingRuntimeInfo { export interface JavaRuntimeData extends ToolingRuntimeInfo { javaRuntimes: JavaRuntimeEntry[]; projectRuntimes: ProjectRuntimeEntry[]; + projectJdkError?: string; } diff --git a/src/java-runtime/utils/upstreamApi.ts b/src/java-runtime/utils/upstreamApi.ts index dde71a30..18da9285 100644 --- a/src/java-runtime/utils/upstreamApi.ts +++ b/src/java-runtime/utils/upstreamApi.ts @@ -2,11 +2,9 @@ // Licensed under the MIT license. import { compareVersions } from "compare-versions"; -import * as fse from "fs-extra"; -import { findRuntimes, getRuntime, IJavaRuntime, JAVA_FILENAME } from 'jdk-utils'; import * as path from "path"; import * as vscode from "vscode"; -import { JavaRuntimeEntry, ToolingRuntimeInfo } from "../types"; +import { ToolingRuntimeInfo } from "../types"; const expandHomeDir = require("expand-home-dir"); @@ -29,101 +27,46 @@ export function getRequiredJdkVersion(): number { return 17; } -export async function getToolingRuntimeInfo(discoveredRuntimes?: IJavaRuntime[]): Promise { +export function getToolingRuntimeInfo(): ToolingRuntimeInfo { const requiredJdkVersion = getRequiredJdkVersion(); - const info: ToolingRuntimeInfo = { requiredJdkVersion, toolingRuntimes: [] }; - const javaExt = vscode.extensions.getExtension<{ javaRequirement?: RequirementsData }>("redhat.java"); + const info: ToolingRuntimeInfo = { requiredJdkVersion }; + const javaExt = vscode.extensions.getExtension<{ javaRequirement?: RequirementsData; status?: string }>("redhat.java"); if (!javaExt) { - info.javaHomeError = "The required extension 'redhat.java' is not installed."; + info.javaHomeWarning = "Language Support for Java (redhat.java) is unavailable. Check that it is installed and enabled."; return info; } - applyReportedRuntime(info); - const preferences = vscode.workspace.getConfiguration(); - const toolingHome = preferences.get("java.jdt.ls.java.home"); - const preferenceName = toolingHome ? "java.jdt.ls.java.home" : "java.home"; - const configuredHome = toolingHome || preferences.get("java.home"); - let configuredRuntime: IJavaRuntime | undefined; - if (configuredHome) { - try { - configuredRuntime = await getRuntime(expandHomeDir(configuredHome), {checkJavac: true, withVersion: true}); - } catch (error) { - console.warn(error); - const message = error instanceof Error ? error.message : String(error); - info.javaHomeWarning = `Unable to inspect the configured '${preferenceName}' (${configuredHome}): ${message}. Java Pack has not changed this setting.`; - } + const api = javaExt.isActive ? javaExt.exports : undefined; + if (api?.status === "Error") { + info.javaHomeError = "redhat.java reports an error. Open the Java logs for details."; } - if (!info.javaHomeWarning && configuredHome && !configuredRuntime?.hasJavac) { - info.javaHomeWarning = `The configured '${preferenceName}' (${configuredHome}) does not point to a valid JDK. Java Pack has not changed this setting.`; - } else if (toolingHome && configuredRuntime && (configuredRuntime.version?.major ?? 0) < requiredJdkVersion) { - info.javaHomeWarning = `The configured '${preferenceName}' uses Java ${configuredRuntime.version?.major ?? 0}, below the required Java ${requiredJdkVersion}. It cannot launch the language server. Runtime selection is controlled by redhat.java; project JDKs can still be older.`; - } - - if (applyReportedRuntime(info)) { + const requirements = api?.javaRequirement; + if (!requirements) { return info; } - - const candidates = new Map(); - const addCandidate = (runtime: IJavaRuntime, type: string) => { - if (runtime.version && runtime.version.major >= requiredJdkVersion && !candidates.has(runtime.homedir)) { - candidates.set(runtime.homedir, { - name: runtime.homedir, - fspath: runtime.homedir, - majorVersion: runtime.version.major, - type - }); - } - }; - if (configuredRuntime?.hasJavac) { - addCandidate(configuredRuntime, preferenceName); + info.javaDotHome = requirements.tooling_jre; + info.toolingJreVersion = requirements.tooling_jre_version; + if (requirements.tooling_jre && requirements.tooling_jre_version < requiredJdkVersion) { + info.javaHomeWarning = `The runtime reported by redhat.java uses Java ${requirements.tooling_jre_version}, below the required Java ${requiredJdkVersion}. Open the Java logs for details.`; } - const jreHome = path.join(javaExt.extensionPath, "jre"); - if (fse.existsSync(jreHome) && fse.statSync(jreHome).isDirectory()) { - for (const candidate of fse.readdirSync(jreHome)) { - const home = path.join(jreHome, candidate); - if (fse.existsSync(path.join(home, "bin", JAVA_FILENAME))) { - const runtime = await getRuntime(home, {withVersion: true}); - if (runtime) { - addCandidate(runtime, "Bundled runtime"); - } - } - } - } - const runtimes = discoveredRuntimes ?? await findRuntimes({checkJavac: true, withVersion: true}); - for (const runtime of runtimes) { - if (runtime.hasJavac && runtime.version && runtime.version.major >= requiredJdkVersion && - (await fse.pathExists(path.join(runtime.homedir, "lib", "rt.jar")) || - await fse.pathExists(path.join(runtime.homedir, "jre", "lib", "rt.jar")) || - await fse.pathExists(path.join(runtime.homedir, "lib", "jrt-fs.jar")))) { - addCandidate(runtime, "Discovered JDK"); - } - } - // Activation may finish while candidate inspection is in progress. - if (applyReportedRuntime(info)) { - return info; - } - info.toolingRuntimes = [...candidates.values()]; - if (info.toolingRuntimes.length === 0) { - info.javaHomeError = getJdkRequirementError(requiredJdkVersion); + const configuredHome = vscode.workspace.getConfiguration().get("java.jdt.ls.java.home"); + if (configuredHome && requirements.tooling_jre && !sameHome(expandHomeDir(configuredHome), requirements.tooling_jre)) { + // Use the version reported by redhat.java, not another local runtime probe. + const configuredVersion = sameHome(expandHomeDir(configuredHome), requirements.java_home) + ? requirements.java_version : undefined; + const configurationWarning = configuredVersion && configuredVersion < requiredJdkVersion + ? `The configured 'java.jdt.ls.java.home' uses Java ${configuredVersion}, below the required Java ${requiredJdkVersion}. redhat.java reports a different language-server runtime.` + : "The configured 'java.jdt.ls.java.home' differs from the runtime reported by redhat.java. Open the Java logs for details."; + info.javaHomeWarning = [info.javaHomeWarning, configurationWarning].filter(Boolean).join(" "); } return info; } -function applyReportedRuntime(info: ToolingRuntimeInfo): boolean { - const javaExt = vscode.extensions.getExtension<{ javaRequirement?: RequirementsData }>("redhat.java"); - const requirements = javaExt?.isActive ? javaExt.exports?.javaRequirement : undefined; - if (!requirements) { +function sameHome(left: string, right?: string): boolean { + if (!right) { return false; } - info.javaDotHome = requirements.tooling_jre; - info.toolingJreVersion = requirements.tooling_jre_version; - info.toolingRuntimes = []; - info.javaHomeError = !requirements.tooling_jre || requirements.tooling_jre_version < info.requiredJdkVersion - ? getJdkRequirementError(info.requiredJdkVersion) - : undefined; - return true; -} - -function getJdkRequirementError(requiredJdkVersion: number): string { - return `Java ${requiredJdkVersion} or more recent is required to run the Java extension. Please download and install a recent JDK. You can still compile your projects with older JDKs by configuring ['java.configuration.runtimes'](https://github.com/redhat-developer/vscode-java/wiki/JDK-Requirements#java.configuration.runtimes)`; + const normalize = (home: string) => process.platform === "win32" + ? path.resolve(home).toLowerCase() : path.resolve(home); + return normalize(left) === normalize(right); } diff --git a/tests/java-runtime.test.js b/tests/java-runtime.test.js index 1f8ac961..f7d0611e 100644 --- a/tests/java-runtime.test.js +++ b/tests/java-runtime.test.js @@ -11,97 +11,48 @@ const ts = require("typescript"); function loadSource(relativePath, mocks, warnings) { const filename = path.join(__dirname, "..", relativePath); const { outputText } = ts.transpileModule(fs.readFileSync(filename, "utf8"), { - compilerOptions: { - module: ts.ModuleKind.CommonJS, - target: ts.ScriptTarget.ES2016, - esModuleInterop: true, - }, + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2016, esModuleInterop: true }, fileName: filename, }); const module = { exports: {} }; vm.runInNewContext(outputText, { - module, - exports: module.exports, + module, exports: module.exports, process, performance, require: name => Object.prototype.hasOwnProperty.call(mocks, name) ? mocks[name] : require(name), console: { warn: warning => warnings.push(warning) }, }, { filename }); return module.exports; } -function createEvent() { - const listeners = new Set(); - return { - subscribe: listener => { - listeners.add(listener); - return { dispose: () => listeners.delete(listener) }; - }, - fire: value => listeners.forEach(listener => listener(value)), - get size() { return listeners.size; }, - }; -} - function setup(version = "1.57.0") { - const extensionPath = path.join(path.parse(__dirname).root, "extensions", "redhat.java"); - const files = new Set(); - const runtimes = new Map(); + const extension = { packageJSON: { version }, isActive: false, exports: {} }; const preferences = {}; - const warnings = []; const discovered = []; const writes = []; + const warnings = []; const messages = []; - const disposal = createEvent(); - const configuration = createEvent(); - const classpath = createEvent(); - let receiveMessage; const calls = { discovery: 0, inspection: 0, activation: 0 }; - const extension = { extensionPath, packageJSON: { version }, isActive: false, exports: {} }; - extension.activate = async () => { - calls.activation++; - extension.isActive = true; - return extension.exports; - }; - const javac = process.platform === "win32" ? "javac.exe" : "javac"; - const java = process.platform === "win32" ? "java.exe" : "java"; + const runtimes = new Map(); + let receiveMessage; + extension.activate = async () => { calls.activation++; return extension.exports; }; const jdkUtils = { - JAVAC_FILENAME: javac, - JAVA_FILENAME: java, - findRuntimes: async () => { - calls.discovery++; - return [...discovered]; - }, - getRuntime: async home => { - calls.inspection++; - return runtimes.get(home); - }, + findRuntimes: async () => { calls.discovery++; return [...discovered]; }, + getRuntime: async home => { calls.inspection++; return runtimes.get(home); }, }; const panel = { webview: { - cspSource: "test", - asWebviewUri: uri => uri.fsPath, - onDidReceiveMessage: handler => { - receiveMessage = handler; - return { dispose() {} }; - }, - postMessage: async message => { - messages.push(message); - return true; - }, + cspSource: "test", asWebviewUri: uri => uri.fsPath, + onDidReceiveMessage: callback => { receiveMessage = callback; return { dispose() {} }; }, + postMessage: async message => { messages.push(message); return true; }, }, - onDidDispose: disposal.subscribe, - reveal() {}, - dispose: () => disposal.fire(), + onDidDispose: () => ({ dispose() {} }), }; const vscode = { - extensions: { - getExtension: id => id === "redhat.java" ? extension : undefined, - }, - Uri: { file: fsPath => ({ fsPath }) }, - ViewColumn: { One: 1 }, - ConfigurationTarget: { Global: 1 }, + extensions: { getExtension: id => id === "redhat.java" ? extension : undefined }, + Uri: { file: fsPath => ({ fsPath }) }, ViewColumn: { One: 1 }, ConfigurationTarget: { Global: 1 }, window: { - createWebviewPanel: () => panel, - showOpenDialog: async () => undefined, + createWebviewPanel: () => panel, showOpenDialog: async () => undefined, showWarningMessage: async message => warnings.push(message), + showErrorMessage: async message => warnings.push(message), }, workspace: { getConfiguration: section => ({ @@ -112,456 +63,211 @@ function setup(version = "1.57.0") { preferences[setting] = value; }, }), - onDidChangeConfiguration: configuration.subscribe, }, commands: { executeCommand: async () => [] }, }; - const mocks = { - vscode, - "jdk-utils": jdkUtils, - "fs-extra": { - pathExists: async filename => files.has(filename), - existsSync: filename => files.has(filename), - statSync: () => ({ isDirectory: () => true }), - readdirSync: () => ["jdk"], - }, - "expand-home-dir": home => home, - }; + const mocks = { vscode, "jdk-utils": jdkUtils, "expand-home-dir": home => home }; const api = loadSource(path.join("src", "java-runtime", "utils", "upstreamApi.ts"), mocks, warnings); - - function addRuntime(major, { source = [], home, embedded = false, jdk = true, valid = true } = {}) { - home = home || (embedded - ? path.join(extensionPath, "jre", "jdk") - : path.join(path.parse(__dirname).root, "jdks", `jdk-${major}-${runtimes.size}`)); - const runtime = { homedir: home, version: { major }, hasJavac: jdk, sources: source }; - runtimes.set(home, runtime); - files.add(home); - files.add(path.join(home, "bin", java)); - if (jdk) { - files.add(path.join(home, "bin", javac)); - } - if (valid) { - files.add(path.join(home, "lib", "jrt-fs.jar")); - } - if (embedded) { - files.add(path.join(extensionPath, "jre")); - } + const loadView = () => loadSource(path.join("src", "java-runtime", "index.ts"), { + ...mocks, "../utils": { getNonce: () => "nonce" }, "../utils/jdt": {}, "../utils/webview": {}, + "./utils/misc": {}, "./utils/upstreamApi": api, + }, warnings); + const addJdk = (major, hasJavac = true) => { + const homedir = path.join(path.parse(__dirname).root, "jdks", `jdk-${major}-${runtimes.size}`); + const runtime = { homedir, hasJavac, version: { major } }; + runtimes.set(homedir, runtime); return runtime; - } - - function loadRuntimeView() { - return loadSource(path.join("src", "java-runtime", "index.ts"), { - ...mocks, - "../utils": { getNonce: () => "nonce" }, - "../utils/jdt": {}, - "../utils/webview": {}, - "./utils/misc": {}, - "./utils/upstreamApi": api, - }, warnings); - } - - async function openView() { - const view = loadRuntimeView(); + }; + const report = (tooling, project = tooling) => { + extension.isActive = true; + extension.exports.javaRequirement = { + tooling_jre: tooling.homedir, tooling_jre_version: tooling.version.major, + java_home: project.homedir, java_version: project.version.major, + }; + }; + const openView = async () => { + const view = loadView(); await view.javaRuntimeCmdHandler({ - extensionPath, - asAbsolutePath: relativePath => path.join(extensionPath, relativePath), + extensionPath: __dirname, asAbsolutePath: relativePath => path.join(__dirname, relativePath), subscriptions: [], }, "unit-test"); return view; - } - + }; return { - api, addRuntime, calls, classpath, configuration, discovered, extension, files, - jdkUtils, loadRuntimeView, messages, openView, panel, preferences, vscode, warnings, writes, + api, extension, preferences, discovered, writes, warnings, messages, calls, jdkUtils, vscode, + addJdk, report, loadView, openView, send: (command, payload = {}) => receiveMessage({ command, ...payload }), }; } -function assertUnresolved(info) { - assert.equal(info.javaDotHome, undefined); - assert.equal(info.toolingJreVersion, undefined); -} - -function candidatePaths(info) { - return Array.from(info.toolingRuntimes, runtime => runtime.fspath).sort(); -} - for (const [version, minimum] of [ - ["1.38.0", 17], - ["1.39.0", 21], - ["1.56.0", 21], - ["1.57.0", 25], - ["1.57.2026093000", 25], - ["1.58.0", 25], + ["1.38.0", 17], ["1.39.0", 21], ["1.56.0", 21], + ["1.57.0", 25], ["1.57.2026093000", 25], ["1.58.0", 25], ]) { - test(`redhat.java ${version} uses JDK ${minimum} as the tooling minimum`, async () => { + test(`redhat.java ${version} shares a JDK ${minimum} tooling minimum`, () => { const s = setup(version); assert.equal(s.api.getRequiredJdkVersion(), minimum); - const jdk = s.addRuntime(minimum); - s.discovered.push(jdk); - const info = await s.api.getToolingRuntimeInfo(); - assertUnresolved(info); - assert.deepEqual(candidatePaths(info), [jdk.homedir]); - assert.equal(info.javaHomeError, undefined); + assert.equal(s.api.getToolingRuntimeInfo().requiredJdkVersion, minimum); }); } -for (let version = 17; version < 25; version++) { - test(`JDK ${version} alone does not satisfy redhat.java 1.57.0`, async () => { - const s = setup(); - s.discovered.push(s.addRuntime(version)); - const info = await s.api.getToolingRuntimeInfo(); - assertUnresolved(info); - assert.equal(info.toolingRuntimes.length, 0); - assert.match(info.javaHomeError, /Java 25 or more recent is required/); - assert.equal(await s.loadRuntimeView().validateJavaRuntime(), false); - }); - - test(`an explicit JDK ${version} is explained without selecting bundled JDK 25 or changing settings`, async () => { - const s = setup(); - const bundled = s.addRuntime(25, { embedded: true, jdk: false }); - const project = s.addRuntime(version); - s.preferences["java.jdt.ls.java.home"] = project.homedir; - const info = await s.api.getToolingRuntimeInfo(); - assertUnresolved(info); - assert.deepEqual(candidatePaths(info), [bundled.homedir]); - assert.equal(info.javaHomeError, undefined); - assert.match(info.javaHomeWarning, /below the required Java 25/); - assert.equal(s.preferences["java.jdt.ls.java.home"], project.homedir); - assert.equal(s.writes.length, 0); - assert.equal(s.calls.activation, 0); - }); -} - -for (const version of [25, 27]) { - test(`external JDK ${version} is an available candidate, not a selected runtime`, async () => { - const s = setup(); - const jdk = s.addRuntime(version); - s.discovered.push(jdk); - assert.equal(await s.loadRuntimeView().validateJavaRuntime(), true); - const info = await s.api.getToolingRuntimeInfo(); - assertUnresolved(info); - assert.deepEqual(candidatePaths(info), [jdk.homedir]); - }); -} - -test("bundled JDK 25 satisfies availability without a separate JDK 27 installation", async () => { - const s = setup(); - const bundled = s.addRuntime(25, { embedded: true, jdk: false }); - assert.equal(await s.loadRuntimeView().validateJavaRuntime(), true); - const info = await s.api.getToolingRuntimeInfo(); - assertUnresolved(info); - assert.deepEqual(candidatePaths(info), [bundled.homedir]); - assert.equal(info.toolingRuntimes[0].type, "Bundled runtime"); - assert.equal(s.writes.length, 0); -}); - -test("an under-minimum bundled runtime is excluded without selecting an external JDK", async () => { +test("an inactive extension provides no runtime result and is not probed or activated", () => { const s = setup(); - s.addRuntime(21, { embedded: true }); - const external = s.addRuntime(25, { source: ["JAVA_HOME"] }); - s.discovered.push(external); - const info = await s.api.getToolingRuntimeInfo(); - assertUnresolved(info); - assert.deepEqual(candidatePaths(info), [external.homedir]); -}); - -test("an under-minimum explicit JDK does not trigger an independent fallback selection", async () => { - const s = setup(); - const project = s.addRuntime(21); - const tooling = s.addRuntime(25, { source: ["PATH"] }); - s.preferences["java.jdt.ls.java.home"] = project.homedir; - s.discovered.push(project, tooling); - const info = await s.api.getToolingRuntimeInfo(); - assertUnresolved(info); - assert.deepEqual(candidatePaths(info), [tooling.homedir]); - assert.equal(s.preferences["java.jdt.ls.java.home"], project.homedir); -}); - -test("a qualified explicit tooling JDK and bundled runtime are both candidates until redhat.java reports a choice", async () => { - const s = setup(); - const bundled = s.addRuntime(27, { embedded: true }); - const explicit = s.addRuntime(25); - s.preferences["java.jdt.ls.java.home"] = explicit.homedir; - const info = await s.api.getToolingRuntimeInfo(); - assertUnresolved(info); - assert.deepEqual(candidatePaths(info), [bundled.homedir, explicit.homedir].sort()); -}); - -test("deprecated java.home remains unchanged and an older project JDK is not a tooling candidate", async () => { - const s = setup(); - const bundled = s.addRuntime(25, { embedded: true }); - const project = s.addRuntime(8); - s.preferences["java.home"] = project.homedir; - const info = await s.api.getToolingRuntimeInfo(); - assertUnresolved(info); - assert.deepEqual(candidatePaths(info), [bundled.homedir]); + s.preferences["java.jdt.ls.java.home"] = "missing-jdk"; + s.discovered.push(s.addJdk(25), s.addJdk(27)); + Object.defineProperty(s.extension, "exports", { get() { throw new Error("Do not read an inactive extension's exports"); } }); + const info = s.api.getToolingRuntimeInfo(); + assert.equal(info.javaDotHome, undefined); + assert.equal(info.javaHomeError, undefined); assert.equal(info.javaHomeWarning, undefined); - assert.equal(s.preferences["java.home"], project.homedir); - assert.equal(s.writes.length, 0); -}); - -test("a configured tooling JDK is inspected without applying it or deprecated java.home", async () => { - const s = setup(); - const explicit = s.addRuntime(25); - s.preferences["java.jdt.ls.java.home"] = explicit.homedir; - s.preferences["java.home"] = s.addRuntime(27).homedir; - const info = await s.api.getToolingRuntimeInfo(); - assertUnresolved(info); - assert.deepEqual(candidatePaths(info), [explicit.homedir]); - assert.equal(s.writes.length, 0); -}); - -test("listing tooling candidates does not alter the older default project runtime", async () => { - const s = setup(); - const bundled = s.addRuntime(25, { embedded: true }); - const project = s.addRuntime(8); - s.preferences["java.configuration.runtimes"] = [ - { path: s.addRuntime(21).homedir }, - { path: project.homedir, default: true }, - { path: s.addRuntime(27).homedir }, - ]; - const external = s.addRuntime(27, { source: ["JAVA_HOME"] }); - s.discovered.push(external); - const before = JSON.stringify(s.preferences); - const info = await s.api.getToolingRuntimeInfo(); - assertUnresolved(info); - assert.deepEqual(candidatePaths(info), [bundled.homedir, external.homedir].sort()); - assert.equal(JSON.stringify(s.preferences), before); + assert.deepEqual(s.calls, { discovery: 0, inspection: 0, activation: 0 }); assert.equal(s.writes.length, 0); }); -test("discovery lists every qualified candidate without ranking runtime sources", async () => { - const s = setup(); - const environment = s.addRuntime(25, { source: ["JAVA_HOME"] }); - s.discovered.push( - s.addRuntime(24, { source: ["JDK_HOME"] }), - s.addRuntime(27, { source: ["SDKMAN"] }), - s.addRuntime(28), - environment, - ); - const info = await s.api.getToolingRuntimeInfo(); - assertUnresolved(info); - assert.equal(info.toolingRuntimes.length, 3); - assert.ok(candidatePaths(info).includes(environment.homedir)); -}); - -test("JDK_HOME=25 and JAVA_HOME=27 do not cause Java Pack to choose either runtime", async () => { - const s = setup(); - const jdkHome = s.addRuntime(25, { source: ["JDK_HOME"] }); - const javaHome = s.addRuntime(27, { source: ["JAVA_HOME"] }); - s.preferences["java.jdt.ls.java.home"] = s.addRuntime(21).homedir; - for (const discovered of [[jdkHome, javaHome], [javaHome, jdkHome]]) { - s.discovered.splice(0, s.discovered.length, ...discovered); - const info = await s.api.getToolingRuntimeInfo(); - assertUnresolved(info); - assert.deepEqual(candidatePaths(info), [jdkHome.homedir, javaHome.homedir].sort()); - } - assert.equal(s.writes.length, 0); - assert.equal(s.calls.activation, 0); -}); - -test("duplicate detected and configured candidates are listed only once", async () => { - const s = setup(); - const jdk = s.addRuntime(25); - s.preferences["java.jdt.ls.java.home"] = jdk.homedir; - s.discovered.push(jdk, jdk); - assert.deepEqual(candidatePaths(await s.api.getToolingRuntimeInfo()), [jdk.homedir]); -}); - -test("discovery excludes installations missing the runtime libraries", async () => { - const s = setup(); - const valid = s.addRuntime(25); - s.discovered.push(s.addRuntime(27, { valid: false }), valid); - assert.deepEqual(candidatePaths(await s.api.getToolingRuntimeInfo()), [valid.homedir]); +test("an active extension without requirements is unknown, not a startup failure", () => { + const s = setup("1.38.0"); + s.extension.isActive = true; + s.extension.exports.status = "Starting"; + const info = s.api.getToolingRuntimeInfo(); + assert.equal(info.javaDotHome, undefined); + assert.equal(info.javaHomeError, undefined); + assert.equal(s.calls.discovery, 0); }); -for (const preference of ["java.jdt.ls.java.home", "java.home"]) { - test(`a missing ${preference} path is explained without changing settings or inventing a fallback`, async () => { +for (const version of [17, 21, 25, 27]) { + test(`reported JDK ${version} is displayed without replacement or readiness claims`, () => { const s = setup(); - s.preferences[preference] = path.join(path.parse(__dirname).root, "missing-jdk"); - s.discovered.push(s.addRuntime(25)); - const info = await s.api.getToolingRuntimeInfo(); - assertUnresolved(info); - assert.ok(info.javaHomeWarning.includes(preference)); - assert.match(info.javaHomeWarning, /does not point to a valid JDK/); + const actual = s.addJdk(version); + s.report(actual); + s.discovered.push(s.addJdk(27)); + const info = s.api.getToolingRuntimeInfo(); + assert.equal(info.javaDotHome, actual.homedir); + assert.equal(info.toolingJreVersion, version); + assert.equal(info.javaHomeError, undefined); + if (version < 25) { + assert.match(info.javaHomeWarning, /below the required Java 25/); + } + assert.deepEqual(s.calls, { discovery: 0, inspection: 0, activation: 0 }); assert.equal(s.writes.length, 0); }); } -test("an explicitly configured JRE is not listed as an external tooling JDK", async () => { - const s = setup(); - s.preferences["java.jdt.ls.java.home"] = s.addRuntime(25, { jdk: false }).homedir; - const info = await s.api.getToolingRuntimeInfo(); - assert.match(info.javaHomeWarning, /does not point to a valid JDK/); - assert.equal(info.toolingRuntimes.length, 0); -}); - -test("an invalid configured bin directory is explained without modifying the setting", async () => { +test("an unavailable extension is reported without inventing a JDK failure", () => { const s = setup(); - const jdk = s.addRuntime(25); - const bin = path.join(jdk.homedir, "bin"); - s.files.add(bin); - s.preferences["java.jdt.ls.java.home"] = bin; - const info = await s.api.getToolingRuntimeInfo(); - assert.match(info.javaHomeWarning, /does not point to a valid JDK/); - assert.equal(s.preferences["java.jdt.ls.java.home"], bin); + s.vscode.extensions.getExtension = () => undefined; + const info = s.api.getToolingRuntimeInfo(); + assert.match(info.javaHomeWarning, /installed and enabled/); + assert.equal(info.javaHomeError, undefined); }); -test("runtime discovery failures reject instead of leaving requirements unresolved", async () => { +test("a reported upstream error is displayed without inventing its cause or opening a popup", () => { const s = setup(); - s.jdkUtils.findRuntimes = async () => { throw new Error("Discovery failed"); }; - await assert.rejects(s.api.getToolingRuntimeInfo(), /Discovery failed/); - assert.equal(await s.loadRuntimeView().validateJavaRuntime(), false); + s.extension.isActive = true; + s.extension.exports.status = "Error"; + const info = s.api.getToolingRuntimeInfo(); + assert.match(info.javaHomeError, /redhat.java reports an error/); + assert.equal(info.javaDotHome, undefined); + s.report(s.addJdk(25)); + assert.match(s.api.getToolingRuntimeInfo().javaHomeError, /redhat.java reports an error/); + assert.equal(s.warnings.length, 0); }); -test("the active language extension's actual runtime is authoritative", async () => { +test("a known under-minimum setting is explained using only upstream-reported versions", () => { const s = setup(); - const actual = { - tooling_jre: s.addRuntime(25).homedir, - tooling_jre_version: 25, - java_home: s.addRuntime(8).homedir, - java_version: 8, - }; - s.extension.isActive = true; - s.extension.exports.javaRequirement = actual; - s.preferences["java.jdt.ls.java.home"] = s.addRuntime(27).homedir; - const info = await s.api.getToolingRuntimeInfo(); - assert.equal(info.javaDotHome, actual.tooling_jre); - assert.equal(info.toolingJreVersion, 25); - assert.equal(info.toolingRuntimes.length, 0); - assert.equal(s.calls.discovery, 0); + const configured = s.addJdk(21); + const actual = s.addJdk(25); + s.preferences["java.jdt.ls.java.home"] = configured.homedir; + s.report(actual, configured); + const info = s.api.getToolingRuntimeInfo(); + assert.match(info.javaHomeWarning, /uses Java 21, below the required Java 25/); + assert.equal(info.javaDotHome, actual.homedir); + assert.equal(info.javaHomeError, undefined); + assert.equal(s.calls.inspection, 0); assert.equal(s.writes.length, 0); }); -test("an active extension without the requirements API still reports an undetermined runtime", async () => { - const s = setup("1.38.0"); - s.extension.isActive = true; - const jdk = s.addRuntime(17); - s.discovered.push(jdk); - const info = await s.api.getToolingRuntimeInfo(); - assertUnresolved(info); - assert.deepEqual(candidatePaths(info), [jdk.homedir]); -}); - -test("an under-minimum runtime exported by the language extension cannot report readiness", async () => { +test("an unexplained setting difference does not fabricate a version or fallback cause", () => { const s = setup(); - s.extension.isActive = true; - s.extension.exports.javaRequirement = { - tooling_jre: s.addRuntime(24).homedir, - tooling_jre_version: 24, - }; - s.discovered.push(s.addRuntime(25)); - const info = await s.api.getToolingRuntimeInfo(); - assert.match(info.javaHomeError, /Java 25 or more recent is required/); - assert.equal(info.javaDotHome, s.extension.exports.javaRequirement.tooling_jre); - assert.equal(info.toolingJreVersion, 24); - assert.equal(await s.loadRuntimeView().validateJavaRuntime(), false); - assert.equal(s.calls.discovery, 0); + const actual = s.addJdk(27); + s.report(actual); + s.preferences["java.jdt.ls.java.home"] = "uninspected-setting"; + const info = s.api.getToolingRuntimeInfo(); + assert.match(info.javaHomeWarning, /differs from the runtime reported/); + assert.doesNotMatch(info.javaHomeWarning, /below|invalid|missing/); + assert.equal(s.calls.inspection, 0); }); -test("runtime entries separate bundled tooling candidates from older project JDK choices", async () => { +test("equivalent configured paths do not produce an ignored-setting warning", () => { const s = setup(); - const bundled = s.addRuntime(25, { embedded: true }); - const project = s.addRuntime(8); - s.discovered.push(project); - s.preferences["java.configuration.runtimes"] = [{ path: project.homedir, default: true }]; - const entries = await s.loadRuntimeView().findJavaRuntimeEntries(); - assert.equal(entries.requiredJdkVersion, 25); - assertUnresolved(entries); - assert.deepEqual(candidatePaths(entries), [bundled.homedir]); - assert.equal(entries.javaHomeError, undefined); - assert.equal(entries.javaRuntimes[0].majorVersion, 8); + const actual = s.addJdk(25); + s.report(actual); + s.preferences["java.jdt.ls.java.home"] = actual.homedir + path.sep; + assert.equal(s.api.getToolingRuntimeInfo().javaHomeWarning, undefined); }); -test("runtime entries report a tooling error while retaining older project JDK choices", async () => { +test("configuration differences do not hide an under-minimum reported runtime", () => { const s = setup(); - s.discovered.push(s.addRuntime(17), s.addRuntime(21)); - const entries = await s.loadRuntimeView().findJavaRuntimeEntries(); - assert.equal(entries.requiredJdkVersion, 25); - assert.match(entries.javaHomeError, /Java 25 or more recent is required/); - assert.equal(entries.javaDotHome, undefined); - assert.equal(entries.javaRuntimes.length, 2); + s.report(s.addJdk(21)); + s.preferences["java.jdt.ls.java.home"] = "different-setting"; + const info = s.api.getToolingRuntimeInfo(); + assert.match(info.javaHomeWarning, /below the required Java 25/); + assert.match(info.javaHomeWarning, /differs from the runtime reported/); }); -test("a missing language extension reports an error while preserving installation guidance", async () => { +test("an older default project runtime is not diagnosed as an invalid tooling setting", () => { const s = setup(); - s.vscode.extensions.getExtension = () => undefined; - s.discovered.push(s.addRuntime(25)); - assert.equal(s.api.getRequiredJdkVersion(), 25); - assert.match((await s.api.getToolingRuntimeInfo()).javaHomeError, /'redhat.java' is not installed/); - assert.equal(await s.loadRuntimeView().validateJavaRuntime(), false); - const entries = await s.loadRuntimeView().findJavaRuntimeEntries(); - assert.equal(entries.requiredJdkVersion, 25); - assert.match(entries.javaHomeError, /'redhat.java' is not installed/); + const project = s.addJdk(8); + s.report(s.addJdk(25), project); + s.preferences["java.home"] = project.homedir; + s.preferences["java.configuration.runtimes"] = [{ path: project.homedir, default: true }]; + const before = JSON.stringify(s.preferences); + assert.equal(s.api.getToolingRuntimeInfo().javaHomeWarning, undefined); + assert.equal(JSON.stringify(s.preferences), before); + assert.equal(s.writes.length, 0); }); -test("manual refresh transitions from candidates to the authoritative JDK 27 without activating or configuring Java", async () => { +test("project discovery errors do not hide or invalidate the reported tooling runtime", async () => { const s = setup(); - const jdkHome = s.addRuntime(25, { source: ["JDK_HOME"] }); - const javaHome = s.addRuntime(27, { source: ["JAVA_HOME"] }); - s.discovered.push(jdkHome, javaHome); - await s.openView(); - await s.send("onWillListRuntimes"); - assertUnresolved(s.messages.at(-1).args); - assert.deepEqual(candidatePaths(s.messages.at(-1).args), [jdkHome.homedir, javaHome.homedir].sort()); - assert.equal(s.configuration.size, 0); - - const messagesBeforeActivation = s.messages.length; - s.extension.isActive = true; - s.extension.exports = { - javaRequirement: { tooling_jre: javaHome.homedir, tooling_jre_version: 27 }, - onDidClasspathUpdate: s.classpath.subscribe, - }; - await new Promise(setImmediate); - assert.equal(s.messages.length, messagesBeforeActivation); - await s.send("onWillListRuntimes"); - assert.equal(s.messages.at(-1).args.javaDotHome, javaHome.homedir); - assert.equal(s.messages.at(-1).args.toolingJreVersion, 27); - assert.equal(s.messages.at(-1).args.toolingRuntimes.length, 0); - assert.equal(s.classpath.size, 0); - assert.equal(s.calls.activation, 0); - assert.equal(s.writes.length, 0); - s.panel.dispose(); - assert.equal(s.classpath.size, 0); - assert.equal(s.configuration.size, 0); + const actual = s.addJdk(25); + s.report(actual); + s.jdkUtils.findRuntimes = async () => { throw new Error("Project discovery failed"); }; + const entries = await s.loadView().findJavaRuntimeEntries(); + assert.equal(entries.javaDotHome, actual.homedir); + assert.equal(entries.toolingJreVersion, 25); + assert.equal(entries.javaHomeError, undefined); + assert.match(entries.projectJdkError, /Project discovery failed/); }); -test("a pending runtime view does not add activation or configuration listeners", async () => { +test("a failed project inventory is visible without claiming an unknown runtime has failed", async () => { const s = setup(); - await s.openView(); - assert.equal(s.classpath.size, 0); - assert.equal(s.configuration.size, 0); - s.panel.dispose(); - assert.equal(s.configuration.size, 0); - assert.equal(s.calls.activation, 0); + s.jdkUtils.findRuntimes = async () => { throw "Project discovery failed"; }; + const entries = await s.loadView().findJavaRuntimeEntries(); + assert.equal(entries.javaDotHome, undefined); + assert.equal(entries.javaHomeError, undefined); + assert.match(entries.projectJdkError, /Project discovery failed/); }); -test("refresh preserves the existing JDK cache but reads the current authoritative runtime", async () => { +test("project cache remains unchanged while refresh reads the latest upstream result", async () => { const s = setup(); - const jdk = s.addRuntime(25); - s.discovered.push(jdk); - const view = s.loadRuntimeView(); - assertUnresolved(await view.findJavaRuntimeEntries()); - const actual = s.addRuntime(27); - s.extension.isActive = true; - s.extension.exports.javaRequirement = { tooling_jre: actual.homedir, tooling_jre_version: 27 }; - s.discovered.push(actual); + s.discovered.push(s.addJdk(8)); + const view = s.loadView(); + assert.equal((await view.findJavaRuntimeEntries()).javaDotHome, undefined); + const actual = s.addJdk(27); + s.report(actual); const entries = await view.findJavaRuntimeEntries(); assert.equal(entries.javaDotHome, actual.homedir); - assert.equal(entries.toolingJreVersion, 27); - assert.equal(entries.javaRuntimes.length, 1); + assert.equal(entries.javaRuntimes[0].majorVersion, 8); assert.equal(s.calls.discovery, 1); - assert.equal(entries.javaHomeError, undefined); + assert.equal(s.calls.inspection, 0); + assert.equal(s.calls.activation, 0); }); for (const version of [25, 27]) { - test(`only explicitly locating JDK ${version} saves the tooling setting, not the actual runtime`, async () => { + test(`only explicitly locating JDK ${version} saves the tooling setting`, async () => { const s = setup(); - const selected = s.addRuntime(version); - const actual = s.addRuntime(27); - s.extension.isActive = true; - s.extension.exports.javaRequirement = { tooling_jre: actual.homedir, tooling_jre_version: 27 }; + const selected = s.addJdk(version); + const actual = s.addJdk(27); + s.report(actual); s.vscode.window.showOpenDialog = async () => [{ fsPath: selected.homedir }]; await s.openView(); assert.equal(s.writes.length, 0); @@ -569,109 +275,77 @@ for (const version of [25, 27]) { assert.deepEqual(s.writes, [{ setting: "java.jdt.ls.java.home", value: selected.homedir }]); assert.equal(s.messages.at(-1).args.javaDotHome, actual.homedir); assert.equal(s.calls.activation, 0); - s.panel.dispose(); }); } for (const [version, jdk] of [[24, true], [25, false]]) { - test(`locating ${jdk ? "an under-minimum JDK" : "a JRE"} does not save the tooling setting`, async () => { + test(`locating ${jdk ? "an under-minimum JDK" : "a JRE"} only warns, without saving it`, async () => { const s = setup(); - const invalid = s.addRuntime(version, { jdk }); + const invalid = s.addJdk(version, jdk); s.vscode.window.showOpenDialog = async () => [{ fsPath: invalid.homedir }]; await s.openView(); await s.send("onWillBrowseForJDK"); assert.equal(s.writes.length, 0); assert.match(s.warnings.at(-1), jdk ? /Java 25 or more recent is required/ : /not a valid JDK/); - s.panel.dispose(); }); } -test("manual refresh updates configuration warnings without selecting or modifying a runtime", async () => { +test("a project scan failure is displayed only as a project inventory problem", async () => { const s = setup(); - const jdk = s.addRuntime(25); - s.discovered.push(jdk); + s.jdkUtils.findRuntimes = async () => { throw new Error("Project discovery failed"); }; await s.openView(); - s.preferences["java.jdt.ls.java.home"] = s.addRuntime(21).homedir; - assert.equal(s.configuration.size, 0); await s.send("onWillListRuntimes"); - const entries = s.messages.at(-1).args; - assertUnresolved(entries); - assert.match(entries.javaHomeWarning, /below the required Java 25/); - assert.deepEqual(candidatePaths(entries), [jdk.homedir]); + assert.match(s.messages.at(-1).args.projectJdkError, /Project discovery failed/); + assert.equal(s.messages.at(-1).args.javaHomeError, undefined); assert.equal(s.writes.length, 0); - s.panel.dispose(); -}); - -test("candidate-discovery failures are surfaced in the runtime view", async () => { - const s = setup(); - s.jdkUtils.findRuntimes = async () => { throw new Error("Discovery failed"); }; - await s.openView(); - await s.send("onWillListRuntimes"); - assert.match(s.messages.at(-1).args.javaHomeError, /Discovery failed/); - assert.ok(s.warnings.some(warning => warning.message === "Discovery failed")); - s.panel.dispose(); -}); - -test("activation during candidate discovery cannot return a stale guessed runtime", async () => { - const s = setup(); - const jdkHome = s.addRuntime(25, { source: ["JDK_HOME"] }); - const javaHome = s.addRuntime(27, { source: ["JAVA_HOME"] }); - let finishDiscovery; - s.jdkUtils.findRuntimes = () => new Promise(resolve => { finishDiscovery = resolve; }); - const request = s.api.getToolingRuntimeInfo(); - s.extension.isActive = true; - s.extension.exports.javaRequirement = { tooling_jre: javaHome.homedir, tooling_jre_version: 27 }; - finishDiscovery([jdkHome, javaHome]); - const info = await request; - assert.equal(info.javaDotHome, javaHome.homedir); - assert.equal(info.toolingJreVersion, 27); - assert.equal(info.toolingRuntimes.length, 0); -}); - -test("failure to inspect a setting does not hide the authoritative runtime", async () => { - const s = setup(); - const actual = s.addRuntime(27); - s.extension.isActive = true; - s.extension.exports.javaRequirement = { tooling_jre: actual.homedir, tooling_jre_version: 27 }; - s.preferences["java.jdt.ls.java.home"] = s.addRuntime(25).homedir; - s.jdkUtils.getRuntime = async () => { throw new Error("Inspection failed"); }; - const info = await s.api.getToolingRuntimeInfo(); - assert.equal(info.javaDotHome, actual.homedir); - assert.equal(info.javaHomeError, undefined); - assert.match(info.javaHomeWarning, /Inspection failed/); - assert.equal(s.calls.discovery, 0); -}); - -test("failure to discover project JDK choices does not invent or hide the authoritative runtime", async () => { - const s = setup(); - const actual = s.addRuntime(27); - s.extension.isActive = true; - s.extension.exports.javaRequirement = { tooling_jre: actual.homedir, tooling_jre_version: 27 }; - s.jdkUtils.findRuntimes = async () => { throw new Error("Discovery failed"); }; - const entries = await s.loadRuntimeView().findJavaRuntimeEntries(); - assert.equal(entries.javaDotHome, actual.homedir); - assert.equal(entries.toolingJreVersion, 27); - assert.match(entries.javaHomeError, /Discovery failed/); -}); - -test("classpath updates remain observable when the language extension has not reported a runtime", async () => { - const s = setup("1.38.0"); - s.extension.isActive = true; - s.extension.exports.onDidClasspathUpdate = s.classpath.subscribe; - s.discovered.push(s.addRuntime(17)); - await s.openView(); - assert.equal(s.classpath.size, 1); - s.classpath.fire(); - await new Promise(setImmediate); - assertUnresolved(s.messages.at(-1).args); - assert.equal(s.configuration.size, 0); - s.panel.dispose(); - assert.equal(s.classpath.size, 0); + assert.equal(s.calls.activation, 0); }); -test("non-Error discovery failures are still visible rather than appearing as successful empty results", async () => { +test("startup preserves welcome and release-note scheduling without runtime preflight or setup", async () => { const s = setup(); - s.jdkUtils.findRuntimes = async () => { throw "Discovery failed"; }; - const entries = await s.loadRuntimeView().findJavaRuntimeEntries(); - assert.equal(entries.javaHomeError, "Discovery failed"); + const scheduled = []; + const commands = []; + const presented = []; + let probes = 0; + const Serializer = class {}; + const noop = () => {}; + s.preferences["java.help.firstView"] = "welcome"; + s.preferences["java.help.showReleaseNotes"] = true; + s.vscode.languages = { registerCodeActionsProvider: noop }; + s.vscode.window.registerWebviewPanelSerializer = noop; + s.vscode.commands.executeCommand = async command => { commands.push(command); }; + const extension = loadSource(path.join("src", "extension.ts"), { + vscode: s.vscode, + "vscode-extension-telemetry-wrapper": { instrumentOperation: (name, action) => context => action(name, context) }, + "./beginner-tips": { BeginnerTipsViewSerializer: Serializer }, + "./commands": { initialize: noop }, + "./daemon": { initDaemon: noop }, + "./exp": { initialize: noop }, + "./ext-guide": { JavaExtGuideViewSerializer: Serializer }, + "./formatter-settings": { initFormatterSettingsEditorProvider: noop }, + "./formatter-settings/RemoteProfileProvider": { initRemoteProfileProvider: noop }, + "./install-jdk": { InstallJdkViewSerializer: Serializer }, + "./java-runtime": { + JavaRuntimeViewSerializer: Serializer, + validateJavaRuntime: async () => { probes++; return false; }, + }, + "./misc": { HelpViewType: { None: "none" }, showReleaseNotesOnStart: () => presented.push("releaseNotes") }, + "./overview": { OverviewViewSerializer: Serializer }, + "./providers/CodeActionProvider": { CodeActionProvider: Serializer }, + "./recommendation": { initialize: noop }, + "./utils": { initialize: noop }, + "./utils/globalState": { KEY_SHOW_WHEN_USING_JAVA: "welcome" }, + "./utils/scheduler": { scheduleAction: async name => { scheduled.push(name); return name; } }, + "./welcome": { WelcomeViewSerializer: Serializer, showWelcomeWebview: () => presented.push("welcome") }, + "./project-settings/projectSettingsView": { ProjectSettingsViewSerializer: Serializer }, + "./utils/telemetryFilter": {}, + }, s.warnings); + await extension.activate({ + subscriptions: [], globalState: { setKeysForSync: noop, get: () => true }, + }); + assert.equal(probes, 0); + assert.deepEqual(commands, []); + assert.deepEqual(scheduled, ["showFirstView", "showReleaseNotes"]); + assert.deepEqual(presented, ["welcome", "releaseNotes"]); + assert.equal(s.calls.activation, 0); }); From 6826bcda4b322525ffa39d2c66c79e337ff9de82 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Thu, 8 Oct 2026 13:50:00 +0800 Subject: [PATCH 04/15] test: use state-driven super implementation navigation --- .../java-go-to-super-implementation.yaml | 46 ++++++++----------- 1 file changed, 19 insertions(+), 27 deletions(-) diff --git a/test-plans/java-go-to-super-implementation.yaml b/test-plans/java-go-to-super-implementation.yaml index 12e84376..269d5c82 100644 --- a/test-plans/java-go-to-super-implementation.yaml +++ b/test-plans/java-go-to-super-implementation.yaml @@ -4,7 +4,8 @@ # "Go to super implementation" link in the hover popup of an overriding # method was not clickable in 1.55.0. This plan hovers the overriding # method, clicks the hover link, and verifies the editor navigates to the -# super (base-class) implementation. +# super (base-class) implementation. The product fix is +# redhat-developer/vscode-java#4440; this plan still exercises the actual link. # # Verifies: LS ready -> hover overriding method -> "Go to super # implementation" hover link is present and clickable -> editor opens @@ -12,6 +13,7 @@ # # Prerequisites: # - JDK 11+ installed and available on PATH (the workflow installs JDK 21) +# - AutoTest 0.7.32+ for state-driven hover and active editor tab verification # # Usage: autotest run test-plans/java-go-to-super-implementation.yaml @@ -25,8 +27,7 @@ setup: extension: "redhat.java" extensions: - "vscjava.vscode-java-pack" - # vscode-java 1.55.0 stable contains the #4438 regression covered by this - # plan; use the pre-release carrying the fix on PR runs as schedules do. + # Keep pre-release regression coverage; --vsix selects release bits explicitly. preRelease: true vscodeVersion: "stable" workspace: "../test-fixtures/super-implementation" @@ -53,41 +54,32 @@ steps: verify: "Derived.java is open in the editor and shows the overriding greet() method" verifyEditor: contains: "public void greet" + verifyEditorTab: + title: "Derived.java" + active: true waitBefore: 5 timeout: 15 - # ── Hover the overriding method ─────────────────────────────── - # The first "greet" occurrence in the visible editor is the overriding - # declaration. Hovering it renders vscode-java's hover popup which - # includes the "Go to super implementation" link. The after-step - # screenshot captures the open popup; the LLM verify confirms the link - # is actually shown (the regression for #4438 is exactly this link - # being absent / not rendered as a clickable link). - - id: "hover-greet" - action: "hoverOnText greet" - verify: "A hover popup is open over the overriding greet() method, ready for the following link action" - waitBefore: 5 - timeout: 30 - # The popup can still show "Loading..." when the screenshot is captured on - # macOS. The next step deterministically proves that the link is available. - skipLlmVerify: true - - # ── Click the hover link ────────────────────────────────────── - # This is the regression assertion for #4438: the link must be a - # clickable element that triggers navigation to the super method. + # Wait for the actual clickable link, restoring a dismissed hover in the + # same operation. Loading text and a merely visible popup are not readiness. - id: "click-super-link" - action: "clickHoverAction Go to super implementation" + action: 'hoverAndClickAction "greet" "Go to Super Implementation"' verify: "The 'Go to super implementation' hover link was clicked and the editor navigated from Derived.java to the base class — Base.java is now opening with the cursor moving onto Base#greet()" - timeout: 15 + verifyEditorTab: + title: "Base.java" + active: true + timeout: 30 # ── Verify navigation landed on Base#greet() ────────────────── - id: "verify-base-open" - action: "wait 2 seconds" + action: "wait 0 seconds" verify: "Base.java is the active editor tab and the cursor sits on the base greet() method (Hello from Base body visible) — navigation to the super implementation succeeded via the hover link" verifyEditor: - fileName: "Base.java" contains: "Hello from Base" + verifyEditorTab: + title: "Base.java" + active: true timeout: 15 - # Navigation completed in the previous step, so this settle-and-assert + # Navigation completed in the previous step, so this assertion # step is visually unchanged by design. verifyEditor is authoritative. skipLlmVerify: true From 0f84622f7b912b3f49b2131610a41b7ed3120ab4 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Thu, 8 Oct 2026 14:16:38 +0800 Subject: [PATCH 05/15] fix: preserve manual JDK setup without runtime metadata --- README.md | 2 +- src/java-runtime/assets/ProjectJDKPanel.tsx | 2 ++ src/java-runtime/assets/ToolingJDKPanel.tsx | 23 ++----------- .../assets/components/JDKActions.tsx | 32 +++++++++++++++++++ 4 files changed, 38 insertions(+), 21 deletions(-) create mode 100644 src/java-runtime/assets/components/JDKActions.tsx diff --git a/README.md b/README.md index 27fd5740..621ae8c5 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ By installing Extension Pack for Java, the following extensions are installed: The JDK used to launch the Java language server is separate from the JDK used to compile and run your projects. The tooling minimum depends on the installed Language Support for Java extension: `redhat.java` 1.57.0 and newer requires JDK 25+, versions 1.39.0-1.56.x require JDK 21+, and earlier supported versions require JDK 17+. A compatible bundled runtime satisfies the tooling requirement; Java 27 project support does not require JDK 27 just to launch the language server. -Use **Java: Configure Java Runtime** to inspect the language-server runtime reported by `redhat.java` and configure project JDKs. If no result is available, Java Pack displays an informational message without treating it as a startup failure or blocking project configuration. Use **Refresh** after the language extension starts, or **Open Java Logs** for details. Runtime selection and startup diagnostics belong to `redhat.java`; Java Pack does not probe tooling candidates, activate the language extension to obtain runtime information, or automatically open runtime configuration based on its own checks. Locating a JDK explicitly saves your choice to `java.jdt.ls.java.home`. Older project targets remain supported through `java.configuration.runtimes` and your Maven or Gradle build configuration. +Use **Java: Configure Java Runtime** to inspect the language-server runtime reported by `redhat.java` and configure project JDKs. If no result is available, Java Pack displays an informational message without treating it as a startup failure or blocking project configuration, and keeps explicit JDK browsing and installation available even when no projects are detected. Use **Refresh** after the language extension starts, or **Open Java Logs** for details. Runtime selection and startup diagnostics belong to `redhat.java`; Java Pack does not probe tooling candidates, activate the language extension to obtain runtime information, or automatically open runtime configuration based on its own checks. Locating a JDK explicitly saves your choice to `java.jdt.ls.java.home`. Older project targets remain supported through `java.configuration.runtimes` and your Maven or Gradle build configuration. ## Other Recommendations diff --git a/src/java-runtime/assets/ProjectJDKPanel.tsx b/src/java-runtime/assets/ProjectJDKPanel.tsx index 16b6b28c..722ab40b 100644 --- a/src/java-runtime/assets/ProjectJDKPanel.tsx +++ b/src/java-runtime/assets/ProjectJDKPanel.tsx @@ -13,6 +13,7 @@ import { useState } from "react"; import { encodeCommandUriWithTelemetry, ProjectType } from "../../utils/webview"; import { JavaRuntimeEntry, ProjectRuntimeEntry } from "../types"; import { DefaultJDKSelector } from "./components/DefaultJDKSelector"; +import { JDKActions } from "./components/JDKActions"; import { ProjectTypeHint } from "./components/ProjectTypeHint"; import { onWillListRuntimes, openBuildScript } from "./vscode.api"; @@ -86,6 +87,7 @@ export function ProjectJDKPanel({ jdkEntries, projectRuntimes, javaDotHome, tool

JDK {requiredJdkVersion}+ is required for the language server only; your projects can use older JDKs. Open language-server runtime settings.

{javaHomeWarning &&

{javaHomeWarning} Open Java Logs.

} {projectJdkError &&

{projectJdkError}

} + {!javaDotHome && } {projectEntries.length > 0 &&

Manage Java runtime for your projects. If you don't have a valid Java runtime, you can download one.

} { projectEntries.length > 0 ? diff --git a/src/java-runtime/assets/ToolingJDKPanel.tsx b/src/java-runtime/assets/ToolingJDKPanel.tsx index fecb4e03..476eb792 100644 --- a/src/java-runtime/assets/ToolingJDKPanel.tsx +++ b/src/java-runtime/assets/ToolingJDKPanel.tsx @@ -3,22 +3,11 @@ import "@vscode-elements/elements/dist/vscode-button/index.js"; -import { useState } from "react"; import { ToolingRuntimeInfo } from "../types"; -import { onWillBrowseForJDK, onWillListRuntimes, onWillRunCommandFromWebview } from './vscode.api'; +import { JDKActions } from "./components/JDKActions"; +import { onWillListRuntimes } from './vscode.api'; export function ToolingJDKPanel({ javaHomeError, javaHomeWarning, javaDotHome, toolingJreVersion, requiredJdkVersion }: ToolingRuntimeInfo) { - const [isDirty, setIsDirty] = useState(false); - - const onClickBrowseJDKButton = () => { - onWillBrowseForJDK(); - setIsDirty(true); - }; - - const onClickInstallButton = () => { - onWillRunCommandFromWebview("java.runtime", "download", "java.installJdk"); - }; - return (

Configure Runtime for Language Server

@@ -34,13 +23,7 @@ export function ToolingJDKPanel({ javaHomeError, javaHomeWarning, javaDotHome, t

Open Java Logs for details. Runtime selection and startup diagnostics are provided by redhat.java.

-
- Locate an Existing JDK - {isDirty && Reload} -
- + Refresh
); diff --git a/src/java-runtime/assets/components/JDKActions.tsx b/src/java-runtime/assets/components/JDKActions.tsx new file mode 100644 index 00000000..ab52a6ef --- /dev/null +++ b/src/java-runtime/assets/components/JDKActions.tsx @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import "@vscode-elements/elements/dist/vscode-button/index.js"; + +import { useState } from "react"; +import { onWillBrowseForJDK, onWillRunCommandFromWebview } from "../vscode.api"; + +export function JDKActions() { + const [isDirty, setIsDirty] = useState(false); + + const onClickBrowseJDKButton = () => { + onWillBrowseForJDK(); + setIsDirty(true); + }; + + const onClickInstallButton = () => { + onWillRunCommandFromWebview("java.runtime", "download", "java.installJdk"); + }; + + return ( + <> +
+ Locate an Existing JDK + {isDirty && Reload} +
+ + + ); +} From 84ca49b354e07fe1973de8755d167da768f862fc Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Thu, 8 Oct 2026 15:16:05 +0800 Subject: [PATCH 06/15] test: verify compatible Gradle JDK imports and builds Keep Gradle 8.5 with a JDK 21 daemon and test a real Java 25 project with Gradle 9.8.1. Assert generated import metadata, dependency-backed completion, clean builds, runtime output and target bytecode. Provision matching CLI/JDK paths in CI without changing automatic JVM selection. --- .github/workflows/e2e-autotest.yml | 19 ++ test-fixtures/gradle-import/.gitignore | 7 + test-fixtures/gradle-import/README.md | 24 +++ test-fixtures/gradle-import/build.gradle | 41 +++++ test-fixtures/gradle-import/settings.gradle | 1 + .../src/java21/example/GradleSmoke21.java | 10 ++ .../src/java25/example/GradleSmoke25.java | 12 ++ test-fixtures/gradle-import/verify.mjs | 58 ++++++ test-plans/java-gradle-java25.yaml | 152 ++++++++++------ test-plans/java-gradle.yaml | 166 +++++++++++------- 10 files changed, 379 insertions(+), 111 deletions(-) create mode 100644 test-fixtures/gradle-import/.gitignore create mode 100644 test-fixtures/gradle-import/README.md create mode 100644 test-fixtures/gradle-import/build.gradle create mode 100644 test-fixtures/gradle-import/settings.gradle create mode 100644 test-fixtures/gradle-import/src/java21/example/GradleSmoke21.java create mode 100644 test-fixtures/gradle-import/src/java25/example/GradleSmoke25.java create mode 100644 test-fixtures/gradle-import/verify.mjs diff --git a/.github/workflows/e2e-autotest.yml b/.github/workflows/e2e-autotest.yml index 89ac93d8..d40c9390 100644 --- a/.github/workflows/e2e-autotest.yml +++ b/.github/workflows/e2e-autotest.yml @@ -141,6 +141,7 @@ jobs: if: contains(matrix.plan, 'java25') shell: pwsh run: | + "JAVA25_HOME=$env:JAVA_HOME" >> $env:GITHUB_ENV $planFile = "test-plans/${{ matrix.plan }}.yaml" if ($IsWindows) { $target = "C:\Program Files\Java\jdk-25" @@ -164,6 +165,24 @@ jobs: distribution: temurin java-version: 21 + - name: Configure JDK 21 for Gradle import tests + if: matrix.plan == 'java-gradle' || matrix.plan == 'java-gradle-java25' + shell: pwsh + run: | + "JAVA21_HOME=$env:JAVA_HOME" >> $env:GITHUB_ENV + if ("${{ matrix.plan }}" -eq "java-gradle") { + $planFile = "test-plans/${{ matrix.plan }}.yaml" + # JSON escaping also handles Windows installation paths in double-quoted YAML. + $jdkPath = ($env:JAVA_HOME | ConvertTo-Json -Compress).Trim('"') + (Get-Content $planFile -Raw).Replace('C:\\Program Files\\Java\\jdk-21', $jdkPath) | Set-Content $planFile + } + + - name: Setup matching Gradle CLI for import tests + if: matrix.plan == 'java-gradle' || matrix.plan == 'java-gradle-java25' + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + with: + gradle-version: ${{ matrix.plan == 'java-gradle-java25' && '9.8.1' || '8.5' }} + - name: Install resolved AutoTest CLI run: | npm install -g "@vscjava/vscode-autotest@${{ needs.discover.outputs.autotest_version }}" diff --git a/test-fixtures/gradle-import/.gitignore b/test-fixtures/gradle-import/.gitignore new file mode 100644 index 00000000..b345d1d8 --- /dev/null +++ b/test-fixtures/gradle-import/.gitignore @@ -0,0 +1,7 @@ +.autotest/ +.classpath +.gradle/ +.project +.settings/ +bin/ +build/ diff --git a/test-fixtures/gradle-import/README.md b/test-fixtures/gradle-import/README.md new file mode 100644 index 00000000..ec61bd3f --- /dev/null +++ b/test-fixtures/gradle-import/README.md @@ -0,0 +1,24 @@ +# Gradle import fixture + +| Plan | Gradle | Daemon/toolchain | Source | +|---|---|---|---| +| `java-gradle` | 8.5 | JDK 21 | `src/java21` | +| `java-gradle-java25` | 9.8.1 | JDK 25 | `src/java25`, including the finalized `ScopedValue` API | + +This wrapper-free fixture uses each plan's explicit Gradle version and JVM. +Configuration fails if the importer uses the wrong Gradle/JDK pair. +The plans intentionally do not test wrapper precedence or automatic compatible-JDK +selection; explicit settings avoid conflating those behaviors with import coverage. + +Successful IDE import requires freshly generated Gradle project/classpath metadata +(Buildship or the Gradle Build Server importer), +the expected compiler source level, and completion of a Commons Lang API not present +in the fixture source text. Generic keyword completion is not sufficient. +The temporary invalid completion probe is removed and saved before building. + +CI installs the matching Gradle CLI and preserves `JAVA21_HOME`/`JAVA25_HOME`. +For local runs, set the applicable variable, put that Gradle version on `PATH`, +and update the plan's JDK paths for your installation. The terminal verifier runs +a clean build, checks the actual Gradle and Java versions, program output and class +file version, and emits its success marker only after all checks pass. +Generated IDE metadata and build logs are not fixture inputs. diff --git a/test-fixtures/gradle-import/build.gradle b/test-fixtures/gradle-import/build.gradle new file mode 100644 index 00000000..f9698456 --- /dev/null +++ b/test-fixtures/gradle-import/build.gradle @@ -0,0 +1,41 @@ +plugins { + id 'java' + id 'application' +} + +def javaRelease = (findProperty('javaRelease') ?: '21').toInteger() +def expectedGradle = [21: '8.5', 25: '9.8.1'][javaRelease] +if (expectedGradle == null) { + throw new GradleException("Unsupported test Java release: ${javaRelease}") +} +if (gradle.gradleVersion != expectedGradle || JavaVersion.current().majorVersion != javaRelease.toString()) { + throw new GradleException("Expected Gradle ${expectedGradle}/Java ${javaRelease}, got Gradle ${gradle.gradleVersion}/Java ${JavaVersion.current()}") +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(javaRelease) + } +} + +sourceSets { + main { + java.srcDirs = ["src/java${javaRelease}"] + } +} + +tasks.withType(JavaCompile).configureEach { + options.release.set(javaRelease) +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.apache.commons:commons-lang3:3.18.0' +} + +application { + mainClass = "example.GradleSmoke${javaRelease}" +} diff --git a/test-fixtures/gradle-import/settings.gradle b/test-fixtures/gradle-import/settings.gradle new file mode 100644 index 00000000..1035127c --- /dev/null +++ b/test-fixtures/gradle-import/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'gradle-import-regression' diff --git a/test-fixtures/gradle-import/src/java21/example/GradleSmoke21.java b/test-fixtures/gradle-import/src/java21/example/GradleSmoke21.java new file mode 100644 index 00000000..1d3e9d39 --- /dev/null +++ b/test-fixtures/gradle-import/src/java21/example/GradleSmoke21.java @@ -0,0 +1,10 @@ +package example; + +import org.apache.commons.lang3.StringUtils; + +public class GradleSmoke21 { + public static void main(String[] args) { + String dependency = StringUtils.upperCase("gradle"); + System.out.println("GRADLE_PROJECT:" + Runtime.version().feature() + ":" + dependency); + } +} diff --git a/test-fixtures/gradle-import/src/java25/example/GradleSmoke25.java b/test-fixtures/gradle-import/src/java25/example/GradleSmoke25.java new file mode 100644 index 00000000..7616406f --- /dev/null +++ b/test-fixtures/gradle-import/src/java25/example/GradleSmoke25.java @@ -0,0 +1,12 @@ +package example; + +import org.apache.commons.lang3.StringUtils; + +public class GradleSmoke25 { + public static void main(String[] args) { + String dependency = StringUtils.upperCase("gradle"); + ScopedValue value = ScopedValue.newInstance(); + String result = ScopedValue.where(value, dependency).call(value::get); + System.out.println("GRADLE_PROJECT:" + Runtime.version().feature() + ":" + result); + } +} diff --git a/test-fixtures/gradle-import/verify.mjs b/test-fixtures/gradle-import/verify.mjs new file mode 100644 index 00000000..981b7b45 --- /dev/null +++ b/test-fixtures/gradle-import/verify.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const workspace = path.dirname(fileURLToPath(import.meta.url)); +const [release, gradleVersion] = process.argv.slice(2); +assert.ok(release === "21" || release === "25", "Expected Java 21 or Java 25"); +assert.equal(gradleVersion, release === "21" ? "8.5" : "9.8.1", "Unexpected Gradle/JDK pair"); +const javaHome = process.env[`JAVA${release}_HOME`]; +assert.ok(javaHome, `Set JAVA${release}_HOME to the project JDK`); + +const env = { ...process.env, JAVA_HOME: javaHome }; +const pathKey = Object.keys(env).find(key => key.toLowerCase() === "path") ?? "PATH"; +env[pathKey] = `${path.join(javaHome, "bin")}${path.delimiter}${env[pathKey] ?? ""}`; +const java = path.join(javaHome, "bin", process.platform === "win32" ? "java.exe" : "java"); +const logDirectory = path.join(workspace, ".autotest"); +mkdirSync(logDirectory, { recursive: true }); + +function run(command, args, logName) { + const result = spawnSync(command, args, { + cwd: workspace, + env, + encoding: "utf8", + timeout: 240_000, + maxBuffer: 16 * 1024 * 1024, + }); + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`; + writeFileSync(path.join(logDirectory, logName), output); + if (result.error) throw result.error; + assert.equal(result.status, 0, `Command failed (${result.status ?? result.signal}): ${output}`); + return output; +} + +function gradle(args, logName) { + return process.platform === "win32" + ? run(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", `gradle ${args.join(" ")}`], logName) + : run("gradle", args, logName); +} + +const javaVersion = run(java, ["-XshowSettings:properties", "-version"], "java-version.log"); +assert.match(javaVersion, new RegExp(`java\\.specification\\.version\\s*=\\s*${release}(?:\\r?\\n|$)`)); +const toolVersion = gradle(["--version"], "gradle-version.log"); +assert.ok(toolVersion.split(/\r?\n/).includes(`Gradle ${gradleVersion}`), toolVersion); + +const output = gradle( + ["--no-daemon", "--console=plain", "--quiet", `-PjavaRelease=${release}`, "clean", "classes", "run"], + "build-and-run.log", +); +console.log(output); +assert.ok(output.split(/\r?\n/).includes(`GRADLE_PROJECT:${release}:GRADLE`), output); + +const bytecode = readFileSync(path.join(workspace, "build", "classes", "java", "main", "example", `GradleSmoke${release}.class`)); +assert.equal(bytecode.readUInt32BE(0), 0xcafebabe, "Expected a Java class file"); +assert.equal(bytecode.readUInt16BE(6), Number(release) + 44, "Wrong target Java bytecode"); +assert.equal(bytecode.readUInt16BE(4), 0, "These cases do not require preview features"); +console.log(`GRADLE_JAVA${release}_BUILD_PASSED`); diff --git a/test-plans/java-gradle-java25.yaml b/test-plans/java-gradle-java25.yaml index 2462f6e1..f5d7ee2b 100644 --- a/test-plans/java-gradle-java25.yaml +++ b/test-plans/java-gradle-java25.yaml @@ -1,71 +1,123 @@ -# Test Plan: Gradle Java 25 (from vscode-java-pack.wiki) -# -# Source: wiki Test-Plan.md "Gradle - Java 11" scenario (upgraded to Java 25) -# Verify: Open Gradle project with JDK 25 → LS ready → editing experience works -# -# Uses the eclipse.jdt.ls subprojects project (Gradle 8.5) -# -# Prerequisites: -# - eclipse.jdt.ls repo is cloned locally -# - JDK 25 is installed (C:\Program Files\Java\jdk-25) -# -# Usage: autotest run test-plans/java-gradle-java25.yaml - -name: "Gradle Java 25 — JDK 25 Compatibility" +# Gradle 9.8.1 compatibility: the language server, Gradle and project use JDK 25. +# Prerequisites: JDK 25, JAVA25_HOME, and Gradle 9.8.1 on PATH (provisioned by CI). +# Explicit JDK selection is intentional; this does not test automatic fallback. + +name: "Gradle Java 25 — Import, Dependency Completion and Build" description: | - Corresponds to the Gradle Java 11 scenario in the wiki Test Plan (upgraded to Java 25): - Open a Gradle 8.5 project with JDK 25, - verify the language server starts normally and the editing experience works. + Import a real Java 25 project with Gradle 9.8.1 and a JDK 25 daemon. + Verify the Gradle model, Java 25 source level and dependency-backed completion, + then build and run a Java 25 ScopedValue program with Java 25 bytecode. + The incomplete completion probe may temporarily produce a diagnostic; + it is removed and saved before the final zero-error and build checks. setup: extension: "redhat.java" extensions: - "vscjava.vscode-java-pack" vscodeVersion: "stable" - workspace: "../../eclipse.jdt.ls/org.eclipse.jdt.ls.tests/projects/gradle/subprojects" + workspace: "../test-fixtures/gradle-import" timeout: 180 settings: + java.help.showReleaseNotes: false + java.help.firstView: "none" java.jdt.ls.java.home: "C:\\Program Files\\Java\\jdk-25" + java.configuration.updateBuildConfiguration: "automatic" + java.configuration.runtimes: + - name: "JavaSE-25" + path: "C:\\Program Files\\Java\\jdk-25" + default: true + java.import.maven.enabled: false + java.import.gradle.enabled: true + java.import.gradle.wrapper.enabled: false + java.import.gradle.version: "9.8.1" + java.import.gradle.java.home: "C:\\Program Files\\Java\\jdk-25" + java.import.gradle.arguments: + - "-PjavaRelease=25" + java.import.generatesMetadataFilesAtProjectRoot: true steps: - # ── Step 1: Wait for LS ready ──────────────────────────── - # wiki: "check the status bar icon is 👍, and there should be no errors" - id: "ls-ready" action: "waitForLanguageServer" - verify: "Gradle subprojects workspace has loaded under JDK 25; Problems panel shows no errors" verifyProblems: errors: 0 timeout: 300 - skipLlmVerify: true # waitForLanguageServer is authoritative; LLM only sees the same status bar + skipLlmVerify: true + + - id: "verify-gradle-model" + action: "open file GradleSmoke25.java" + verifyFile: + path: "~/.project" + matches: '(org\.eclipse\.buildship\.core\.gradleprojectnature|com\.microsoft\.gradle\.bs\.importer\.GradleBuildServerProjectNature)' + skipLlmVerify: true - # ── Step 2: Open Java file ─────────────────────────────── - # wiki: "Open Foo.java, make sure the editing experience is correctly working" - - id: "open-test1" - action: "open file Test1.java" - verify: "Test1.java file is opened in the editor" + - id: "verify-java-source" + action: "waitForLanguageServer" + verifyEditorTab: + title: "GradleSmoke25.java" + active: true + verifyFile: + path: "~/.settings/org.eclipse.jdt.core.prefs" + contains: "org.eclipse.jdt.core.compiler.source=25" + verifyProblems: + errors: 0 timeout: 15 - # ── Step 3: Verify completion ─────────────────────────── - # verify text describes the rendered popup; verifyCompletion.notEmpty - # is the deterministic ground truth — kept lenient so a transient - # "Loading…" indicator at screenshot time doesn't downgrade. - - id: "verify-completion" - action: "triggerCompletionAt endOfMethod" - verify: "Code completion has been triggered in HelloWorld.java; the IntelliSense popup is being rendered (the language server may briefly show a 'Loading...' indicator while computing suggestions on a cold cache — this is a valid intermediate state since the deterministic verifyCompletion.notEmpty asserts the LS produced completion items)" + - id: "verify-gradle-classpath" + action: "saveFile" + verifyFile: + path: "~/.classpath" + matches: 'org\.eclipse\.buildship\.core\.gradleclasspathcontainer|commons-lang3-3\.18\.0\.jar' + skipLlmVerify: true + + - id: "insert-dependency-probe" + action: "insertLineInFile src/java25/example/GradleSmoke25.java 8 String probe = StringUtils.cap;" + verifyFile: + path: "~/src/java25/example/GradleSmoke25.java" + contains: "String probe = StringUtils.cap;" + skipLlmVerify: true + + - id: "select-completion-line" + action: "goToLine 8" + verifyEditorTab: + title: "GradleSmoke25.java" + active: true + + - id: "move-to-probe-end" + action: "executeVSCodeCommand cursorEnd" + + - id: "move-before-semicolon" + action: "pressKey ArrowLeft" + + - id: "verify-dependency-completion" + action: "triggerCompletion" verifyCompletion: - notEmpty: true - waitBefore: 8 - # Retry once on cold-cache "Loading..." LLM downgrades. - retries: 1 - - # ── Step 4: Verify editing ──────────────────────────────── - - id: "goto-line" - action: "goToLine 3" - - - id: "goto-end" - action: "goToEndOfLine" - - - id: "type-code" - action: "typeInEditor // JDK 25 gradle test marker" - verifyEditor: - contains: "// JDK 25 gradle test marker" + contains: ["capitalize"] + + - id: "dismiss-completion" + action: "pressKey Escape" + + - id: "select-probe-line" + action: "goToLine 8" + + - id: "remove-dependency-probe" + action: "executeVSCodeCommand editor.action.deleteLines" + + - id: "save-source" + action: "saveFile" + verifyFile: + path: "~/src/java25/example/GradleSmoke25.java" + matches: '^(?![\s\S]*String probe = StringUtils\.cap;)' + skipLlmVerify: true + + - id: "open-build-terminal" + action: 'executeVSCodeCommand workbench.action.terminal.new {"cwd":"${workspaceFolder}"}' + skipLlmVerify: true + + - id: "verify-build-and-run" + action: 'executeVSCodeCommand workbench.action.terminal.sendSequence {"text":"node verify.mjs 25 9.8.1\u000d"}' + verifyTerminal: + contains: "GRADLE_JAVA25_BUILD_PASSED" + verifyProblems: + errors: 0 + timeout: 300 + skipLlmVerify: true diff --git a/test-plans/java-gradle.yaml b/test-plans/java-gradle.yaml index 43d8baa6..99696308 100644 --- a/test-plans/java-gradle.yaml +++ b/test-plans/java-gradle.yaml @@ -1,81 +1,125 @@ -# Test Plan: Gradle Project (from vscode-java-pack.wiki) -# -# Source: wiki Test-Plan.md "Gradle" scenario -# Verify: Open Gradle project → LS ready → no errors → Java file editing experience works -# -# Note: Uses the eclipse.jdt.ls subprojects test project (Gradle 8.5), -# because vscode-java's simple-gradle uses Gradle 4.10.2 which is incompatible with modern JDKs. -# -# Prerequisites: -# - eclipse.jdt.ls repo is cloned locally -# - JDK is installed and available -# -# Usage: autotest run test-plans/java-gradle.yaml - -name: "Java Gradle Project — Basic Gradle Project Editing" +# Gradle 8.5 compatibility: the importer/daemon and project use JDK 21. +# The language server uses redhat.java's bundled JDK, independently of Gradle. +# Prerequisites: JDK 21, JAVA21_HOME, and Gradle 8.5 on PATH (provisioned by CI). +# Explicit JDK selection is intentional; this does not test automatic fallback. + +name: "Java Gradle Project — Gradle 8.5 with JDK 21" description: | - Corresponds to the Gradle scenario in the wiki Test Plan: - Open a Gradle 8.5 project, verify the language server starts without errors, - and Java file editing experience works (diagnostics, completion). + Import a Gradle 8.5 project using JDK 21 for the Gradle daemon. + Verify the Gradle model, Java 21 source level and dependency-backed completion, + then build and run the saved project with Java 21 bytecode. + The incomplete completion probe may temporarily produce a diagnostic; + it is removed and saved before the final zero-error and build checks. setup: extension: "redhat.java" extensions: - "vscjava.vscode-java-pack" vscodeVersion: "stable" - workspace: "../../eclipse.jdt.ls/org.eclipse.jdt.ls.tests/projects/gradle/subprojects" - timeout: 180 # Gradle project import can be slow (needs to download Gradle distribution) + workspace: "../test-fixtures/gradle-import" + timeout: 180 + settings: + java.help.showReleaseNotes: false + java.help.firstView: "none" + java.configuration.updateBuildConfiguration: "automatic" + java.configuration.runtimes: + - name: "JavaSE-21" + path: "C:\\Program Files\\Java\\jdk-21" + default: true + java.import.maven.enabled: false + java.import.gradle.enabled: true + java.import.gradle.wrapper.enabled: false + java.import.gradle.version: "8.5" + java.import.gradle.java.home: "C:\\Program Files\\Java\\jdk-21" + java.import.gradle.arguments: + - "-PjavaRelease=21" + java.import.generatesMetadataFilesAtProjectRoot: true steps: - # ── Step 1: Wait for Language Server ready ────────────── - # wiki: "check the status bar icon is 👍, and there should be - # no errors/problems in the problems view." - id: "ls-ready" action: "waitForLanguageServer" - verify: "Gradle workspace has loaded; Problems panel shows no errors" verifyProblems: errors: 0 timeout: 300 - skipLlmVerify: true # waitForLanguageServer is authoritative; LLM only sees the same status bar - - # ── Step 2: Open Foo.java and verify editing experience ─ - # wiki: "Open Foo.java file, make sure the editing experience - # is correctly working including diagnostics, code completion - # and code action" - - id: "open-foo" - action: "open file Test1.java" - verify: "Test1.java file is opened in the editor" + skipLlmVerify: true + + - id: "verify-gradle-model" + action: "open file GradleSmoke21.java" + verifyFile: + path: "~/.project" + matches: '(org\.eclipse\.buildship\.core\.gradleprojectnature|com\.microsoft\.gradle\.bs\.importer\.GradleBuildServerProjectNature)' + skipLlmVerify: true + + - id: "verify-java-source" + action: "waitForLanguageServer" + verifyEditorTab: + title: "GradleSmoke21.java" + active: true + verifyFile: + path: "~/.settings/org.eclipse.jdt.core.prefs" + contains: "org.eclipse.jdt.core.compiler.source=21" + verifyProblems: + errors: 0 timeout: 15 - - id: "verify-completion" - action: "triggerCompletionAt endOfMethod" + - id: "verify-gradle-classpath" + action: "saveFile" + verifyFile: + path: "~/.classpath" + matches: 'org\.eclipse\.buildship\.core\.gradleclasspathcontainer|commons-lang3-3\.18\.0\.jar' + skipLlmVerify: true + + # capitalize is absent from both fixture sources, so a word suggestion + # from existing source text cannot satisfy this dependency completion check. + - id: "insert-dependency-probe" + action: "insertLineInFile src/java21/example/GradleSmoke21.java 8 String probe = StringUtils.cap;" + verifyFile: + path: "~/src/java21/example/GradleSmoke21.java" + contains: "String probe = StringUtils.cap;" + skipLlmVerify: true + + - id: "select-completion-line" + action: "goToLine 8" + verifyEditorTab: + title: "GradleSmoke21.java" + active: true + + - id: "move-to-probe-end" + action: "executeVSCodeCommand cursorEnd" + + - id: "move-before-semicolon" + action: "pressKey ArrowLeft" + + - id: "verify-dependency-completion" + action: "triggerCompletion" verifyCompletion: - notEmpty: true - waitBefore: 3 - # No `verify:` — the on-screen completion menu can lag behind the - # internal completion API on slower CI runners (screenshot may capture - # the "Loading..." indicator while the items are already in the list). - # Deterministic verifyCompletion above is authoritative. - - # Verify the editor can type and save normally - - id: "goto-line" - action: "goToLine 2" - verify: "Cursor moved to line 2" - - - id: "goto-end" - action: "goToEndOfLine" - - - id: "type-comment" - action: "typeInEditor // gradle test marker" - verifyEditor: - contains: "// gradle test marker" - - - id: "save-file" + contains: ["capitalize"] + + - id: "dismiss-completion" + action: "pressKey Escape" + + - id: "select-probe-line" + action: "goToLine 8" + + - id: "remove-dependency-probe" + action: "executeVSCodeCommand editor.action.deleteLines" + + - id: "save-source" action: "saveFile" - verify: "Test1.java is saved to disk" verifyFile: - path: "~/project1/src/main/java/project1/Test1.java" - contains: "// gradle test marker" - # The disk assertion proves saveFile persisted the in-memory edit. The - # screenshot-only check can misread the small tab dirty-dot transition. + path: "~/src/java21/example/GradleSmoke21.java" + matches: '^(?![\s\S]*String probe = StringUtils\.cap;)' + skipLlmVerify: true + + - id: "open-build-terminal" + action: 'executeVSCodeCommand workbench.action.terminal.new {"cwd":"${workspaceFolder}"}' + skipLlmVerify: true + + - id: "verify-build-and-run" + action: 'executeVSCodeCommand workbench.action.terminal.sendSequence {"text":"node verify.mjs 21 8.5\u000d"}' + verifyTerminal: + contains: "GRADLE_JAVA21_BUILD_PASSED" + verifyProblems: + errors: 0 + timeout: 300 skipLlmVerify: true From 99be46a2ae6bc7aeda70a3724e5a6a400a3ae29c Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Thu, 8 Oct 2026 15:48:22 +0800 Subject: [PATCH 07/15] fix: preserve project controls and clarify JDK configuration guidance --- README.md | 6 +- src/beginner-tips/assets/tabs/FaqPanel.tsx | 7 +- src/java-runtime/assets/ProjectJDKPanel.tsx | 4 +- src/java-runtime/assets/index.ts | 3 +- tests/java-runtime.test.js | 113 +++++++++++++++++++- 5 files changed, 126 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 621ae8c5..26a21de9 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,11 @@ By installing Extension Pack for Java, the following extensions are installed: The JDK used to launch the Java language server is separate from the JDK used to compile and run your projects. The tooling minimum depends on the installed Language Support for Java extension: `redhat.java` 1.57.0 and newer requires JDK 25+, versions 1.39.0-1.56.x require JDK 21+, and earlier supported versions require JDK 17+. A compatible bundled runtime satisfies the tooling requirement; Java 27 project support does not require JDK 27 just to launch the language server. -Use **Java: Configure Java Runtime** to inspect the language-server runtime reported by `redhat.java` and configure project JDKs. If no result is available, Java Pack displays an informational message without treating it as a startup failure or blocking project configuration, and keeps explicit JDK browsing and installation available even when no projects are detected. Use **Refresh** after the language extension starts, or **Open Java Logs** for details. Runtime selection and startup diagnostics belong to `redhat.java`; Java Pack does not probe tooling candidates, activate the language extension to obtain runtime information, or automatically open runtime configuration based on its own checks. Locating a JDK explicitly saves your choice to `java.jdt.ls.java.home`. Older project targets remain supported through `java.configuration.runtimes` and your Maven or Gradle build configuration. +**Java: Configure Java Runtime** opens the project-settings JDK page. Its local-JDK picker changes the selected project's JDK, not the JDK used to launch the language server. Older project targets remain supported through `java.configuration.runtimes` and your Maven or Gradle build configuration. + +To set the language-server launch JDK explicitly, configure `java.jdt.ls.java.home`. Open the Java logs (`java.open.logs`) for startup diagnostics. Runtime selection and startup diagnostics belong to `redhat.java`; Java Pack does not probe tooling candidates, activate the language extension to obtain runtime information, or automatically open runtime configuration based on its own checks. + +Previously opened legacy runtime tabs can still be restored for compatibility; they are not opened by the current **Java: Configure Java Runtime** command. These tabs show the runtime reported by `redhat.java`, with **Refresh** and explicit JDK browsing/installation actions when no runtime is reported. Missing information is not treated as a startup failure, and upstream errors are shown alongside any available project configuration. The legacy tab's **Locate an Existing JDK** action saves an explicit tooling choice to `java.jdt.ls.java.home`, unlike the current project-settings picker. ## Other Recommendations diff --git a/src/beginner-tips/assets/tabs/FaqPanel.tsx b/src/beginner-tips/assets/tabs/FaqPanel.tsx index d5ab8ef9..0fca5cee 100644 --- a/src/beginner-tips/assets/tabs/FaqPanel.tsx +++ b/src/beginner-tips/assets/tabs/FaqPanel.tsx @@ -5,8 +5,10 @@ import "@vscode-elements/elements/dist/vscode-table/index.js"; import "@vscode-elements/elements/dist/vscode-table-row/index.js"; import "@vscode-elements/elements/dist/vscode-table-cell/index.js"; import "@vscode-elements/elements/dist/vscode-table-body/index.js"; +import { encodeCommandUriWithTelemetry } from "../../../utils/webview"; export default function FaqPanel({ requiredJdkVersion }: { requiredJdkVersion: number }) { + const toolingSettingsCommand = encodeCommandUriWithTelemetry("java.gettingStarted", "toolingSettings", "workbench.action.openSettings", ["java.jdt.ls.java.home"]); const runtimeSampleCode = ( {"\"java.configuration.runtimes\": [{"}
@@ -49,7 +51,10 @@ export default function FaqPanel({ requiredJdkVersion }: { requiredJdkVersion: n

Why do I see the JDK errors?

- JDK {requiredJdkVersion}+ is required to run the installed Java language support (redhat.java) extension. A compatible bundled runtime can satisfy this requirement; otherwise, a compatible local JDK is needed. The Configure Java Runtime guide shows the runtime reported by redhat.java. If no result is available yet, it displays an informational message rather than diagnosing a startup failure. Runtime selection and startup diagnostics are provided by redhat.java; open the Java logs for details. + JDK {requiredJdkVersion}+ is required to run the installed Java language support (redhat.java) extension. A compatible bundled runtime can satisfy this requirement; otherwise, a compatible local JDK is needed. To set the language-server launch JDK explicitly, configure java.jdt.ls.java.home. Runtime selection and startup diagnostics are provided by redhat.java; open the Java logs for details. +

+

+ The Configure Java Runtime page configures project JDKs, not the language-server launch JDK. Your projects can use older JDKs independently of the tooling requirement.

Can I run my Java 8 project with JDK 1.8?

diff --git a/src/java-runtime/assets/ProjectJDKPanel.tsx b/src/java-runtime/assets/ProjectJDKPanel.tsx index 722ab40b..26c45b4a 100644 --- a/src/java-runtime/assets/ProjectJDKPanel.tsx +++ b/src/java-runtime/assets/ProjectJDKPanel.tsx @@ -23,11 +23,12 @@ interface Props { javaDotHome?: string; toolingJreVersion?: number; requiredJdkVersion: number; + javaHomeError?: string; javaHomeWarning?: string; projectJdkError?: string; } -export function ProjectJDKPanel({ jdkEntries, projectRuntimes, javaDotHome, toolingJreVersion, requiredJdkVersion, javaHomeWarning, projectJdkError }: Props) { +export function ProjectJDKPanel({ jdkEntries, projectRuntimes, javaDotHome, toolingJreVersion, requiredJdkVersion, javaHomeError, javaHomeWarning, projectJdkError }: Props) { const [showHintFor, setShowHintFor] = useState<"Maven" | "Gradle" | "Others" | undefined>(); const projectTypeHint = (projectType: ProjectType) => { @@ -85,6 +86,7 @@ export function ProjectJDKPanel({ jdkEntries, projectRuntimes, javaDotHome, tool

Language server runtime information is not available yet. Use Refresh after redhat.java starts, or open the Java logs for details.

)}

JDK {requiredJdkVersion}+ is required for the language server only; your projects can use older JDKs. Open language-server runtime settings.

+ {javaHomeError &&

{javaHomeError} Open Java Logs.

} {javaHomeWarning &&

{javaHomeWarning} Open Java Logs.

} {projectJdkError &&

{projectJdkError}

} {!javaDotHome && } diff --git a/src/java-runtime/assets/index.ts b/src/java-runtime/assets/index.ts index 8b3659ad..e87a41a3 100644 --- a/src/java-runtime/assets/index.ts +++ b/src/java-runtime/assets/index.ts @@ -23,7 +23,7 @@ window.addEventListener("message", onInitialize); onWillListRuntimes(); function showJavaRuntimeEntries(args: JavaRuntimeData) { - if (args.javaHomeError) { + if (args.javaHomeError && args.projectRuntimes.length === 0) { root.render(createElement(ToolingJDKPanel, args)); } else { root.render(createElement(ProjectJDKPanel, { @@ -32,6 +32,7 @@ function showJavaRuntimeEntries(args: JavaRuntimeData) { javaDotHome: args.javaDotHome, toolingJreVersion: args.toolingJreVersion, requiredJdkVersion: args.requiredJdkVersion, + javaHomeError: args.javaHomeError, javaHomeWarning: args.javaHomeWarning, projectJdkError: args.projectJdkError })); diff --git a/tests/java-runtime.test.js b/tests/java-runtime.test.js index f7d0611e..a0d2a2d4 100644 --- a/tests/java-runtime.test.js +++ b/tests/java-runtime.test.js @@ -8,21 +8,61 @@ const test = require("node:test"); const vm = require("node:vm"); const ts = require("typescript"); -function loadSource(relativePath, mocks, warnings) { +function loadSource(relativePath, mocks, warnings, globals = {}, resolve = require) { const filename = path.join(__dirname, "..", relativePath); const { outputText } = ts.transpileModule(fs.readFileSync(filename, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2016, esModuleInterop: true }, + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2016, esModuleInterop: true, jsx: ts.JsxEmit.ReactJSX }, fileName: filename, }); const module = { exports: {} }; vm.runInNewContext(outputText, { module, exports: module.exports, process, performance, - require: name => Object.prototype.hasOwnProperty.call(mocks, name) ? mocks[name] : require(name), + require: name => Object.prototype.hasOwnProperty.call(mocks, name) ? mocks[name] : resolve(name), console: { warn: warning => warnings.push(warning) }, + ...globals, }, { filename }); return module.exports; } +function setupComponents() { + const { renderToStaticMarkup } = require("react-dom/server"); + const cache = new Map(); + const messages = []; + let receive; + let html; + const globals = { + acquireVsCodeApi: () => ({ postMessage: message => messages.push(message) }), + document: { getElementById: () => ({}) }, + window: { addEventListener: (_, listener) => { receive = listener; } }, + }; + const mocks = { + "react-dom/client": { createRoot: () => ({ render: element => { html = renderToStaticMarkup(element); } }) }, + }; + function load(relativePath) { + if (!cache.has(relativePath)) { + cache.set(relativePath, loadSource(relativePath, mocks, [], globals, name => { + if (name.endsWith(".scss") || name.startsWith("@vscode-elements/")) { + return {}; + } + if (!name.startsWith(".")) { + return require(name); + } + const source = path.resolve(__dirname, "..", path.dirname(relativePath), name); + const filename = fs.existsSync(source + ".tsx") ? source + ".tsx" : source + ".ts"; + return load(path.relative(path.join(__dirname, ".."), filename)); + })); + } + return cache.get(relativePath); + } + return { + load, messages, + show: args => { + receive({ data: { command: "showJavaRuntimeEntries", args } }); + return html; + }, + }; +} + function setup(version = "1.57.0") { const extension = { packageJSON: { version }, isActive: false, exports: {} }; const preferences = {}; @@ -301,6 +341,73 @@ test("a project scan failure is displayed only as a project inventory problem", assert.equal(s.calls.activation, 0); }); +for (const projectType of ["Maven", "Unmanaged folder"]) { + test(`component rendering keeps ${projectType} controls when refresh reports an upstream error`, () => { + const s = setup(); + s.report(s.addJdk(25)); + const components = setupComponents(); + components.load(path.join("src", "java-runtime", "assets", "index.ts")); + const projectRuntimes = [{ name: "sample", rootPath: "file:///sample", projectType, sourceLevel: "17" }]; + const show = () => components.show({ + ...s.api.getToolingRuntimeInfo(), javaRuntimes: [], projectRuntimes, + }); + assert.match(show(), /Configure Runtime for Projects/); + s.extension.exports.status = "Error"; + const html = show(); + assert.match(html, /Configure Runtime for Projects/); + assert.match(html, /redhat.java reports an error/); + assert.match(html, /href="command:java.open.logs"/); + assert.match(html, /sample/); + assert.match(html, /title="Edit"/); + assert.match(html, /Language server runtime reported by redhat.java/); + assert.doesNotMatch(html, /Configure Runtime for Language Server/); + assert.equal(components.messages.length, 1); + assert.equal(components.messages[0].command, "onWillListRuntimes"); + }); +} + +test("component rendering preserves existing tooling error controls without project entries", () => { + const s = setup(); + s.report(s.addJdk(25)); + s.extension.exports.status = "Error"; + const components = setupComponents(); + components.load(path.join("src", "java-runtime", "assets", "index.ts")); + const html = components.show({ + ...s.api.getToolingRuntimeInfo(), javaRuntimes: [], projectRuntimes: [], + }); + assert.match(html, /Configure Runtime for Language Server/); + assert.match(html, /redhat.java reports an error/); + assert.match(html, /Locate an Existing JDK<\/b>/); + assert.match(html, /Install a New JDK<\/b>/); +}); + +test("component rendering leaves unknown metadata informational with manual setup available", () => { + const components = setupComponents(); + components.load(path.join("src", "java-runtime", "assets", "index.ts")); + const html = components.show({ + ...setup().api.getToolingRuntimeInfo(), javaRuntimes: [], projectRuntimes: [], + }); + assert.match(html, /Configure Runtime for Projects/); + assert.match(html, /runtime information is not available yet/); + assert.match(html, /Locate an Existing JDK<\/b>/); + assert.match(html, /Install a New JDK<\/b>/); + assert.doesNotMatch(html, /redhat.java reports an error/); +}); + +test("FAQ component distinguishes project configuration from the language-server setting", () => { + const { createElement } = require("react"); + const { renderToStaticMarkup } = require("react-dom/server"); + const { default: FaqPanel } = setupComponents().load(path.join("src", "beginner-tips", "assets", "tabs", "FaqPanel.tsx")); + const html = renderToStaticMarkup(createElement(FaqPanel, { requiredJdkVersion: 25 })); + assert.match(html, /JDK 25\+/); + assert.match(html, /href="command:java.runtime">Configure Java Runtime<\/a> page configures project JDKs/); + assert.match(html, /href="command:java.open.logs"/); + const settingLinks = [...html.matchAll(/href="command:java.webview.runCommand\?([^"]+)"/g)] + .map(match => JSON.parse(decodeURIComponent(match[1]))); + assert.deepEqual(settingLinks.find(link => link.command === "workbench.action.openSettings").args, ["java.jdt.ls.java.home"]); + assert.doesNotMatch(html, /guide shows the runtime reported/); +}); + test("startup preserves welcome and release-note scheduling without runtime preflight or setup", async () => { const s = setup(); const scheduled = []; From 54f0e2b49aa331365cc409bd6282f21af4d4921f Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Thu, 8 Oct 2026 16:32:30 +0800 Subject: [PATCH 08/15] ci: prepare VSIX overrides once for the UI matrix --- .github/scripts/prepare-vsix.ps1 | 162 +++++++++++++++++++++ .github/scripts/test-prepare-vsix.ps1 | 172 +++++++++++++++++++++++ .github/skills/uitest/SKILL.md | 3 + .github/workflows/e2e-autotest.yml | 194 ++++++++------------------ 4 files changed, 395 insertions(+), 136 deletions(-) create mode 100644 .github/scripts/prepare-vsix.ps1 create mode 100644 .github/scripts/test-prepare-vsix.ps1 diff --git a/.github/scripts/prepare-vsix.ps1 b/.github/scripts/prepare-vsix.ps1 new file mode 100644 index 00000000..c826150c --- /dev/null +++ b/.github/scripts/prepare-vsix.ps1 @@ -0,0 +1,162 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string] $Sources, + [Parameter(Mandatory = $true)] + [string] $OutputDirectory +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" +$platformIds = @("win32-x64", "linux-x64", "darwin-arm64") +$urls = @($Sources -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" } | Select-Object -Unique) +if ($urls.Count -eq 0) { + throw "No VSIX sources supplied" +} +if (Test-Path -LiteralPath $OutputDirectory) { + throw "VSIX output directory already exists: $OutputDirectory" +} + +$downloadCache = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) +$runCache = @{} +$cacheDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "java-pack-vsix-$([guid]::NewGuid())" +New-Item -ItemType Directory -Path $cacheDirectory | Out-Null + +function Get-GitHubHeaders { + param([switch] $Actions) + + $token = if ($Actions -and $env:VSCODE_JAVA_ARTIFACT_TOKEN) { $env:VSCODE_JAVA_ARTIFACT_TOKEN } else { $env:GITHUB_TOKEN } + if (-not $token) { + throw "GitHub API requests require GITHUB_TOKEN or, for Actions artifacts, VSCODE_JAVA_ARTIFACT_TOKEN" + } + return @{ + Accept = "application/vnd.github+json" + "X-GitHub-Api-Version" = "2022-11-28" + "User-Agent" = "vscode-java-pack-autotest" + Authorization = "Bearer $token" + } +} + +function Select-VsixForPlatform { + param( + [AllowEmptyCollection()] + [object[]] $VsixFiles, + [string] $PlatformId + ) + + $platformVsix = $VsixFiles | Where-Object { $_.Name -like "*-$PlatformId-*" } | Sort-Object Name | Select-Object -First 1 + if ($platformVsix) { + return $platformVsix + } + $universalVsix = $VsixFiles | Where-Object { $_.Name -notmatch '-(darwin|linux|win32)-' } | Sort-Object Name | Select-Object -First 1 + if ($universalVsix) { + return $universalVsix + } + throw "No matching VSIX found for platform $PlatformId. Available VSIX files: $($VsixFiles.Name -join ', ')" +} + +function Get-DownloadedVsix { + param([string] $Url) + + if (-not $downloadCache.ContainsKey($Url)) { + $uri = [uri] $Url + if (-not $uri.IsAbsoluteUri -or $uri.Scheme -notin @("https", "http")) { + throw "VSIX downloads require an absolute HTTP(S) URL" + } + $fileName = [System.IO.Path]::GetFileName([uri]::UnescapeDataString($uri.AbsolutePath)) + if (-not $fileName.EndsWith(".vsix", [System.StringComparison]::OrdinalIgnoreCase)) { + throw "VSIX download URL must name a .vsix file" + } + $directory = Join-Path $cacheDirectory "download-$($downloadCache.Count)" + New-Item -ItemType Directory -Path $directory | Out-Null + $filePath = Join-Path $directory $fileName + Write-Host "Downloading VSIX: $fileName" + # Direct and release-asset URLs must not receive GitHub API credentials. + Invoke-WebRequest -Uri $uri -OutFile $filePath -UseBasicParsing | Out-Null + $downloadCache.Add($Url, $filePath) + } + return Get-Item -LiteralPath $downloadCache[$Url] +} + +function Get-GitHubActionsRunVsix { + param([string] $Owner, [string] $Repo, [string] $RunId) + + $key = "$Owner/$Repo/$RunId" + if (-not $runCache.ContainsKey($key)) { + Write-Host "Resolving GitHub Actions run: $Owner/$Repo#$RunId" + $headers = Get-GitHubHeaders -Actions + $artifactsUrl = "https://api.github.com/repos/$Owner/$Repo/actions/runs/$RunId/artifacts?per_page=100" + $artifacts = @((Invoke-RestMethod -Uri $artifactsUrl -Headers $headers -UseBasicParsing).artifacts | Where-Object { -not $_.expired }) + if ($artifacts.Count -eq 0) { + throw "No non-expired artifacts found for GitHub Actions run $Owner/$Repo#$RunId" + } + $artifact = $artifacts | Where-Object { $_.name -eq "vscode-java" } | Select-Object -First 1 + if (-not $artifact -and $artifacts.Count -eq 1) { + $artifact = $artifacts[0] + } + if (-not $artifact) { + throw "No vscode-java artifact found for GitHub Actions run $Owner/$Repo#$RunId. Available artifacts: $($artifacts.name -join ', ')" + } + $archiveUri = [uri] $artifact.archive_download_url + if ($archiveUri.Scheme -ne "https" -or $archiveUri.Host -ne "api.github.com") { + throw "GitHub artifact archive URL must use https://api.github.com" + } + $directory = Join-Path $cacheDirectory "run-$($runCache.Count)" + $zipPath = "$directory.zip" + Write-Host "Downloading Actions artifact: $($artifact.name)" + try { + Invoke-WebRequest -Uri $archiveUri -OutFile $zipPath -Headers $headers -UseBasicParsing | Out-Null + } catch { + throw "Failed to download artifact $($artifact.name) from $Owner/$Repo#$RunId. Cross-repo downloads may require VSCODE_JAVA_ARTIFACT_TOKEN with actions:read access. $($_.Exception.Message)" + } + Expand-Archive -LiteralPath $zipPath -DestinationPath $directory -Force + $files = @(Get-ChildItem -LiteralPath $directory -Recurse -File -Filter "*.vsix") + if ($files.Count -eq 0) { + throw "Artifact $($artifact.name) from $Owner/$Repo#$RunId does not contain VSIX files" + } + $runCache[$key] = $files + } + return $runCache[$key] +} + +try { + foreach ($platformId in $platformIds) { + New-Item -ItemType Directory -Path (Join-Path $OutputDirectory $platformId) -Force | Out-Null + } + foreach ($url in $urls) { + if ($url -match '^https://github\.com/([^/]+)/([^/]+)/releases/tag/(.+)$') { + $owner = $Matches[1]; $repo = $Matches[2]; $tag = $Matches[3] + Write-Host "Resolving GitHub release: $owner/$repo@$tag" + $apiUrl = "https://api.github.com/repos/$owner/$repo/releases/tags/$tag" + $release = Invoke-RestMethod -Uri $apiUrl -Headers (Get-GitHubHeaders) -UseBasicParsing + $assets = @($release.assets | Where-Object { $_.name -like "*.vsix" }) + foreach ($platformId in $platformIds) { + $asset = Select-VsixForPlatform -VsixFiles $assets -PlatformId $platformId + $file = Get-DownloadedVsix -Url $asset.browser_download_url + Copy-Item -LiteralPath $file.FullName -Destination (Join-Path $OutputDirectory "$platformId/$($file.Name)") -Force + } + } elseif ($url -match '^https://github\.com/([^/]+)/([^/]+)/actions/runs/(\d+)') { + $files = @(Get-GitHubActionsRunVsix -Owner $Matches[1] -Repo $Matches[2] -RunId $Matches[3]) + foreach ($platformId in $platformIds) { + $file = Select-VsixForPlatform -VsixFiles $files -PlatformId $platformId + Copy-Item -LiteralPath $file.FullName -Destination (Join-Path $OutputDirectory "$platformId/$($file.Name)") -Force + } + } elseif ($url -match '^\d+$') { + $files = @(Get-GitHubActionsRunVsix -Owner "redhat-developer" -Repo "vscode-java" -RunId $url) + foreach ($platformId in $platformIds) { + $file = Select-VsixForPlatform -VsixFiles $files -PlatformId $platformId + Copy-Item -LiteralPath $file.FullName -Destination (Join-Path $OutputDirectory "$platformId/$($file.Name)") -Force + } + } else { + $file = Get-DownloadedVsix -Url $url + foreach ($platformId in $platformIds) { + Copy-Item -LiteralPath $file.FullName -Destination (Join-Path $OutputDirectory "$platformId/$($file.Name)") -Force + } + } + } + Get-ChildItem -LiteralPath $OutputDirectory -Recurse -File -Filter "*.vsix" | ForEach-Object { + Write-Host "Prepared $($_.Directory.Name): $($_.Name) ($([math]::Round($_.Length / 1MB, 1)) MB)" + } +} finally { + Remove-Item -LiteralPath $cacheDirectory -Recurse -Force +} diff --git a/.github/scripts/test-prepare-vsix.ps1 b/.github/scripts/test-prepare-vsix.ps1 new file mode 100644 index 00000000..bf3b25df --- /dev/null +++ b/.github/scripts/test-prepare-vsix.ps1 @@ -0,0 +1,172 @@ +$ErrorActionPreference = "Stop" +$scriptPath = Join-Path $PSScriptRoot "prepare-vsix.ps1" +$testDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "java-pack-vsix-tests-$([guid]::NewGuid())" +$previousToken = $env:GITHUB_TOKEN +$previousArtifactToken = $env:VSCODE_JAVA_ARTIFACT_TOKEN +$platformIds = @("win32-x64", "linux-x64", "darwin-arm64") +$testState = @{ + restCalls = [System.Collections.Generic.List[object]]::new() + webCalls = [System.Collections.Generic.List[object]]::new() + restFailure = "" + webFailure = "" + caseCount = 0 +} +$releaseUrl = "https://github.com/redhat-developer/vscode-java/releases/tag/v1.57.0" +$runUrl = "https://github.com/redhat-developer/vscode-java/actions/runs/123" +$testState.archiveFiles = @($platformIds | ForEach-Object { "java-$_-1.57.0.vsix" }) +$testState.assets = @($testState.archiveFiles | ForEach-Object { + [pscustomobject]@{ name = $_; browser_download_url = "https://downloads.example/$_" } +}) +$testState.artifacts = @([pscustomobject]@{ + name = "vscode-java" + expired = $false + archive_download_url = "https://api.github.com/repos/redhat-developer/vscode-java/actions/artifacts/1/zip" +}) + +function Assert-True { + param([bool] $Condition, [string] $Message) + if (-not $Condition) { + throw $Message + } +} + +function Invoke-RestMethod { + param([string] $Uri, [hashtable] $Headers, [switch] $UseBasicParsing) + $testState.restCalls.Add([pscustomobject]@{ Uri = $Uri; Headers = $Headers }) + if ($testState.restFailure) { + throw $testState.restFailure + } + if ($Uri -match '/releases/tags/') { + Assert-True ($Headers.Authorization -eq "Bearer $env:GITHUB_TOKEN") "Release metadata must use GITHUB_TOKEN" + return [pscustomobject]@{ assets = $testState.assets } + } + if ($Uri -match '/actions/runs/') { + $token = if ($env:VSCODE_JAVA_ARTIFACT_TOKEN) { $env:VSCODE_JAVA_ARTIFACT_TOKEN } else { $env:GITHUB_TOKEN } + Assert-True ($Headers.Authorization -eq "Bearer $token") "Actions metadata must use the artifact token or GITHUB_TOKEN" + return [pscustomobject]@{ artifacts = $testState.artifacts } + } + throw "Unexpected metadata request" +} + +function Invoke-WebRequest { + param([uri] $Uri, [string] $OutFile, [hashtable] $Headers, [switch] $UseBasicParsing) + $testState.webCalls.Add([pscustomobject]@{ Uri = $Uri; Headers = $Headers }) + if ($testState.webFailure) { + throw $testState.webFailure + } + if ($Uri.Host -eq "api.github.com") { + $token = if ($env:VSCODE_JAVA_ARTIFACT_TOKEN) { $env:VSCODE_JAVA_ARTIFACT_TOKEN } else { $env:GITHUB_TOKEN } + Assert-True ($Headers.Authorization -eq "Bearer $token") "Actions archive must use the artifact token or GITHUB_TOKEN" + } else { + Assert-True (-not $Headers) "Direct/release downloads must not receive API credentials" + } + [System.IO.File]::WriteAllText($OutFile, "fixture") +} + +function Expand-Archive { + param([string] $LiteralPath, [string] $DestinationPath, [switch] $Force) + New-Item -ItemType Directory -Path $DestinationPath -Force | Out-Null + foreach ($name in $testState.archiveFiles) { + [System.IO.File]::WriteAllText((Join-Path $DestinationPath $name), "fixture") + } +} + +function Invoke-TestPreparation { + param([string] $Sources, [string] $ExpectedError = "") + + $testState.caseCount++ + $testState.restCalls.Clear() + $testState.webCalls.Clear() + $output = Join-Path $testDirectory "case-$($testState.caseCount)" + try { + & $scriptPath -Sources $Sources -OutputDirectory $output 6> $null + } catch { + if (-not $ExpectedError -or $_.Exception.Message -notlike "*$ExpectedError*") { + throw + } + return $output + } + Assert-True (-not $ExpectedError) "Expected preparation failure: $ExpectedError" + return $output +} + +function Assert-PlatformFiles { + param([string] $Directory, [switch] $Universal) + foreach ($platformId in $platformIds) { + $files = @(Get-ChildItem -LiteralPath (Join-Path $Directory $platformId) -File) + $expected = if ($Universal) { "extension-1.0.vsix" } else { "java-$platformId-1.57.0.vsix" } + Assert-True ($files.Count -eq 1 -and $files[0].Name -eq $expected) "Wrong bits for $platformId" + } +} + +try { + New-Item -ItemType Directory -Path $testDirectory | Out-Null + $env:GITHUB_TOKEN = "test-github-token" + $env:VSCODE_JAVA_ARTIFACT_TOKEN = "test-artifact-token" + + $testState.assets += [pscustomobject]@{ name = "java-linux-arm64-1.57.0.vsix"; browser_download_url = "https://downloads.example/wrong.vsix" } + $testState.assets += [pscustomobject]@{ name = "extension-1.0.vsix"; browser_download_url = "https://downloads.example/extension-1.0.vsix" } + $output = Invoke-TestPreparation "$releaseUrl, $releaseUrl" + Assert-PlatformFiles $output + Assert-True ($testState.restCalls.Count -eq 1 -and $testState.webCalls.Count -eq 3) "Release must resolve once and download only the three required platform assets" + + $testState.assets = @([pscustomobject]@{ name = "extension-1.0.vsix"; browser_download_url = "https://downloads.example/extension-1.0.vsix" }) + $output = Invoke-TestPreparation $releaseUrl + Assert-PlatformFiles $output -Universal + Assert-True ($testState.restCalls.Count -eq 1 -and $testState.webCalls.Count -eq 1) "Universal release must download once" + + $output = Invoke-TestPreparation "$runUrl,123" + Assert-PlatformFiles $output + Assert-True ($testState.restCalls.Count -eq 1 -and $testState.webCalls.Count -eq 1) "Run URL and numeric alias must share one metadata/archive download" + + $env:VSCODE_JAVA_ARTIFACT_TOKEN = $null + $output = Invoke-TestPreparation $runUrl + Assert-PlatformFiles $output + Assert-True ($testState.restCalls.Count -eq 1 -and $testState.webCalls.Count -eq 1) "Actions fallback token must retain single-download behavior" + + $testState.artifacts[0].name = "sole-artifact" + $testState.archiveFiles = @("extension-1.0.vsix") + $output = Invoke-TestPreparation $runUrl + Assert-PlatformFiles $output -Universal + + $env:GITHUB_TOKEN = $null + $output = Invoke-TestPreparation " https://downloads.example/extension-1.0.vsix?download=1,https://downloads.example/extension-1.0.vsix?download=1 " + Assert-PlatformFiles $output -Universal + Assert-True ($testState.restCalls.Count -eq 0 -and $testState.webCalls.Count -eq 1) "Direct URLs must download once without API authentication" + + Invoke-TestPreparation $releaseUrl "GitHub API requests require" | Out-Null + Assert-True ($testState.restCalls.Count -eq 0) "Missing token must fail before calling the release API" + $env:GITHUB_TOKEN = "test-github-token" + + $testState.assets = @([pscustomobject]@{ name = "java-win32-x64-1.57.0.vsix"; browser_download_url = "https://downloads.example/java-win32-x64-1.57.0.vsix" }) + Invoke-TestPreparation $releaseUrl "No matching VSIX found for platform linux-x64" | Out-Null + $testState.assets = @() + Invoke-TestPreparation $releaseUrl "No matching VSIX found" | Out-Null + + $testState.restFailure = "rate limit exceeded" + Invoke-TestPreparation $releaseUrl "rate limit exceeded" | Out-Null + Assert-True ($testState.webCalls.Count -eq 0) "Metadata failure must not fall back to other bits" + $testState.restFailure = "" + + $testState.artifacts[0].expired = $true + Invoke-TestPreparation $runUrl "No non-expired artifacts" | Out-Null + $testState.artifacts[0].expired = $false + $testState.artifacts[0].archive_download_url = "https://downloads.example/archive.zip" + Invoke-TestPreparation $runUrl "archive URL must use https://api.github.com" | Out-Null + Assert-True ($testState.webCalls.Count -eq 0) "Archive credentials must not be sent to an external host" + $testState.artifacts[0].archive_download_url = "https://api.github.com/repos/redhat-developer/vscode-java/actions/artifacts/1/zip" + + $testState.archiveFiles = @() + Invoke-TestPreparation $runUrl "does not contain VSIX files" | Out-Null + $testState.webFailure = "forbidden" + Invoke-TestPreparation $runUrl "Cross-repo downloads may require VSCODE_JAVA_ARTIFACT_TOKEN" | Out-Null + $testState.webFailure = "" + + Invoke-TestPreparation "https://downloads.example/file.zip" "must name a .vsix file" | Out-Null + Invoke-TestPreparation " , " "No VSIX sources supplied" | Out-Null + Write-Host "Passed $($testState.caseCount) VSIX preparation tests." +} finally { + $env:GITHUB_TOKEN = $previousToken + $env:VSCODE_JAVA_ARTIFACT_TOKEN = $previousArtifactToken + Remove-Item -LiteralPath $testDirectory -Recurse -Force +} diff --git a/.github/skills/uitest/SKILL.md b/.github/skills/uitest/SKILL.md index 2746e160..0f0534ef 100644 --- a/.github/skills/uitest/SKILL.md +++ b/.github/skills/uitest/SKILL.md @@ -77,3 +77,6 @@ The repository workflow `.github/workflows/e2e-autotest.yml` builds a branch VSI Use workflow dispatch when the user asks to validate in CI or across platforms. The `test_plan` input can target a single plan; leave it empty to run all plans. +For manual runs with `vsix_urls`, `prepare-bits` resolves each release/run once, downloads the Windows x64, Linux x64, and macOS ARM64 VSIX files, and uploads platform artifacts. Matrix jobs reuse those bits alongside the branch-built `pack-vsix`; they do not query release APIs. Preparation or missing-platform failures block the matrix rather than silently testing Marketplace bits. Runs without supplied URLs skip preparation and keep the existing Marketplace behavior. + +Release API requests use `GITHUB_TOKEN`. Cross-repository Actions artifacts use `VSCODE_JAVA_ARTIFACT_TOKEN` when configured, otherwise `GITHUB_TOKEN`; the token needs `actions:read` access to the source repository. Direct VSIX URLs are downloaded once and distributed unchanged, without GitHub API credentials. diff --git a/.github/workflows/e2e-autotest.yml b/.github/workflows/e2e-autotest.yml index d40c9390..06ee5828 100644 --- a/.github/workflows/e2e-autotest.yml +++ b/.github/workflows/e2e-autotest.yml @@ -102,11 +102,62 @@ jobs: echo "matrix=$matrix" >> "$GITHUB_OUTPUT" echo "Found plans: $matrix" + # Prepare supplied bits once, independently of the test matrix. + prepare-bits: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.vsix_urls != '' }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Test VSIX preparation + shell: pwsh + run: ./.github/scripts/test-prepare-vsix.ps1 + + - name: Prepare platform VSIX files + shell: pwsh + env: + VSIX_SOURCES: ${{ inputs.vsix_urls }} + GITHUB_TOKEN: ${{ github.token }} + VSCODE_JAVA_ARTIFACT_TOKEN: ${{ secrets.VSCODE_JAVA_ARTIFACT_TOKEN }} + run: ./.github/scripts/prepare-vsix.ps1 -Sources $env:VSIX_SOURCES -OutputDirectory prepared-vsix + + - name: Upload Windows bits + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: override-vsix-Windows-X64 + path: prepared-vsix/win32-x64/*.vsix + if-no-files-found: error + compression-level: 0 + overwrite: true + retention-days: 1 + + - name: Upload Linux bits + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: override-vsix-Linux-X64 + path: prepared-vsix/linux-x64/*.vsix + if-no-files-found: error + compression-level: 0 + overwrite: true + retention-days: 1 + + - name: Upload macOS bits + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: override-vsix-macOS-ARM64 + path: prepared-vsix/darwin-arm64/*.vsix + if-no-files-found: error + compression-level: 0 + overwrite: true + retention-days: 1 + # ── Job 2: Run each test plan in parallel ─────────────── e2e-test: - needs: [discover, build-pack] + needs: [discover, build-pack, prepare-bits] # build-pack is skipped on schedule; PR/manual runs install this branch's pack VSIX first. - if: ${{ always() && needs.discover.result == 'success' && (github.event_name == 'schedule' || needs.build-pack.result == 'success') }} + # Skipped preparation is allowed only without overrides; failure must not fall back to Marketplace. + if: ${{ always() && needs.discover.result == 'success' && (github.event_name == 'schedule' || needs.build-pack.result == 'success') && (needs.prepare-bits.result == 'success' || (needs.prepare-bits.result == 'skipped' && (github.event_name != 'workflow_dispatch' || inputs.vsix_urls == ''))) }} runs-on: ${{ matrix.os }} timeout-minutes: 30 strategy: @@ -227,141 +278,12 @@ jobs: name: pack-vsix path: vsix - - name: Download VSIX files + - name: Download prepared platform VSIX files if: ${{ github.event_name == 'workflow_dispatch' && inputs.vsix_urls != '' }} - shell: pwsh - env: - GITHUB_TOKEN: ${{ github.token }} - VSCODE_JAVA_ARTIFACT_TOKEN: ${{ secrets.VSCODE_JAVA_ARTIFACT_TOKEN }} - run: | - New-Item -ItemType Directory -Path vsix -Force | Out-Null - $urls = "${{ inputs.vsix_urls }}" -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" } - - # Map runner OS/arch to vscode-java platform identifiers - $platformMap = @{ "Windows" = "win32"; "Linux" = "linux"; "macOS" = "darwin" } - $archMap = @{ "X64" = "x64"; "ARM64" = "arm64" } - $platform = $platformMap["${{ runner.os }}"] - $arch = $archMap["${{ runner.arch }}"] - $platformId = "$platform-$arch" - Write-Host "Runner platform: $platformId (${{ runner.os }}/${{ runner.arch }})" - - $githubHeaders = @{ - Accept = "application/vnd.github+json" - "X-GitHub-Api-Version" = "2022-11-28" - "User-Agent" = "vscode-java-pack-autotest" - } - $artifactToken = if ($env:VSCODE_JAVA_ARTIFACT_TOKEN) { $env:VSCODE_JAVA_ARTIFACT_TOKEN } else { $env:GITHUB_TOKEN } - $githubArtifactHeaders = $githubHeaders.Clone() - if ($artifactToken) { - $githubArtifactHeaders.Authorization = "Bearer $artifactToken" - } - - function Select-VsixForPlatform { - param( - [Parameter(Mandatory = $true)] - [System.IO.FileInfo[]] $VsixFiles, - [Parameter(Mandatory = $true)] - [string] $PlatformId - ) - - $platformVsix = $VsixFiles | Where-Object { $_.Name -like "*-$PlatformId-*" } | Sort-Object Name | Select-Object -First 1 - if ($platformVsix) { - Write-Host " Found platform-specific VSIX: $($platformVsix.Name)" - return $platformVsix - } - - $universalVsix = $VsixFiles | Where-Object { $_.Name -notmatch '-(darwin|linux|win32)-' } | Sort-Object Name | Select-Object -First 1 - if ($universalVsix) { - Write-Host " No platform-specific VSIX found, using universal: $($universalVsix.Name)" - return $universalVsix - } - - throw "No matching VSIX found for platform $PlatformId. Available VSIX files: $($VsixFiles.Name -join ', ')" - } - - function Download-GitHubActionsRunVsix { - param( - [Parameter(Mandatory = $true)] - [string] $Owner, - [Parameter(Mandatory = $true)] - [string] $Repo, - [Parameter(Mandatory = $true)] - [string] $RunId - ) - - Write-Host "Resolving GitHub Actions run: $Owner/$Repo#$RunId for platform $platformId" - $artifactsUrl = "https://api.github.com/repos/$Owner/$Repo/actions/runs/$RunId/artifacts" - $artifacts = @((Invoke-RestMethod -Uri $artifactsUrl -Headers $githubArtifactHeaders -UseBasicParsing).artifacts | Where-Object { -not $_.expired }) - if (-not $artifacts) { - throw "No non-expired artifacts found for GitHub Actions run $Owner/$Repo#$RunId" - } - - $artifact = $artifacts | Where-Object { $_.name -eq "vscode-java" } | Select-Object -First 1 - if (-not $artifact -and $artifacts.Count -eq 1) { - $artifact = $artifacts | Select-Object -First 1 - } - if (-not $artifact) { - throw "No vscode-java artifact found for GitHub Actions run $Owner/$Repo#$RunId. Available artifacts: $($artifacts.name -join ', ')" - } - - $safeArtifactName = $artifact.name -replace '[^\w.-]', '_' - $downloadDir = Join-Path "downloaded-artifacts" "$Owner-$Repo-$RunId-$safeArtifactName" - $zipPath = "$downloadDir.zip" - New-Item -ItemType Directory -Path $downloadDir -Force | Out-Null - - Write-Host " Downloading artifact: $($artifact.name)" - try { - Invoke-WebRequest -Uri $artifact.archive_download_url -OutFile $zipPath -Headers $githubArtifactHeaders -UseBasicParsing - } catch { - throw "Failed to download artifact $($artifact.name) from $Owner/$Repo#$RunId. Cross-repo artifact downloads may require a VSCODE_JAVA_ARTIFACT_TOKEN secret with actions:read access to the source repository. $($_.Exception.Message)" - } - Expand-Archive -LiteralPath $zipPath -DestinationPath $downloadDir -Force - - $vsixFiles = @(Get-ChildItem $downloadDir -Recurse -Filter "*.vsix") - if ($vsixFiles.Count -eq 0) { - throw "Artifact $($artifact.name) from $Owner/$Repo#$RunId does not contain VSIX files" - } - - $selectedVsix = Select-VsixForPlatform -VsixFiles $vsixFiles -PlatformId $platformId - Copy-Item -LiteralPath $selectedVsix.FullName -Destination (Join-Path "vsix" $selectedVsix.Name) -Force - } - - $resolvedUrls = @() - foreach ($url in $urls) { - if ($url -match '^https://github\.com/([^/]+)/([^/]+)/releases/tag/(.+)$') { - $owner = $Matches[1]; $repo = $Matches[2]; $tag = $Matches[3] - Write-Host "Resolving GitHub release: $owner/$repo@$tag for platform $platformId" - $apiUrl = "https://api.github.com/repos/$owner/$repo/releases/tags/$tag" - $release = Invoke-RestMethod -Uri $apiUrl -Headers $githubHeaders -UseBasicParsing - $platformAsset = $release.assets | Where-Object { $_.name -like "*-$platformId-*" -and $_.name -like "*.vsix" } | Select-Object -First 1 - if ($platformAsset) { - Write-Host " Found platform-specific VSIX: $($platformAsset.name)" - $resolvedUrls += $platformAsset.browser_download_url - } else { - $universalAsset = $release.assets | Where-Object { $_.name -notmatch '-(darwin|linux|win32)-' -and $_.name -like "*.vsix" } | Select-Object -First 1 - if ($universalAsset) { - Write-Host " No platform-specific VSIX found, using universal: $($universalAsset.name)" - $resolvedUrls += $universalAsset.browser_download_url - } else { - Write-Host "::warning::No matching VSIX found in release $owner/$repo@$tag for platform $platformId" - } - } - } elseif ($url -match '^https://github\.com/([^/]+)/([^/]+)/actions/runs/(\d+)') { - Download-GitHubActionsRunVsix -Owner $Matches[1] -Repo $Matches[2] -RunId $Matches[3] - } elseif ($url -match '^\d+$') { - Download-GitHubActionsRunVsix -Owner "redhat-developer" -Repo "vscode-java" -RunId $url - } else { - $resolvedUrls += $url - } - } - - foreach ($url in $resolvedUrls) { - $fileName = [System.IO.Path]::GetFileName(($url -split '\?')[0]) - Write-Host "Downloading: $url → vsix/$fileName" - Invoke-WebRequest -Uri $url -OutFile "vsix/$fileName" -UseBasicParsing - } - Write-Host "Downloaded VSIX files:" - Get-ChildItem vsix -Filter "*.vsix" | ForEach-Object { Write-Host " $($_.Name) ($([math]::Round($_.Length/1MB, 1)) MB)" } + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: override-vsix-${{ runner.os }}-${{ runner.arch }} + path: vsix - name: Use vscode-java-pack as primary extension for VSIX override runs if: ${{ github.event_name == 'workflow_dispatch' && inputs.vsix_urls != '' }} From 637d9ab9fdc59cd1112dca4ad80178057100aaaa Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Thu, 8 Oct 2026 17:50:33 +0800 Subject: [PATCH 09/15] test: bind Gradle UI cases to the CI toolchain --- .github/scripts/bind-gradle-toolchain.ps1 | 71 +++++++++++ .github/skills/uitest/SKILL.md | 2 + .github/workflows/e2e-autotest.yml | 11 ++ test-fixtures/gradle-import/verify.mjs | 35 +++++- test-plans/java-gradle-java25.yaml | 1 + test-plans/java-gradle.yaml | 1 + tests/gradle-toolchain.test.js | 138 ++++++++++++++++++++++ 7 files changed, 253 insertions(+), 6 deletions(-) create mode 100644 .github/scripts/bind-gradle-toolchain.ps1 create mode 100644 tests/gradle-toolchain.test.js diff --git a/.github/scripts/bind-gradle-toolchain.ps1 b/.github/scripts/bind-gradle-toolchain.ps1 new file mode 100644 index 00000000..b456a59d --- /dev/null +++ b/.github/scripts/bind-gradle-toolchain.ps1 @@ -0,0 +1,71 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateSet("java-gradle", "java-gradle-java25")] + [string] $PlanName +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false +$release = if ($PlanName -eq "java-gradle-java25") { "25" } else { "21" } +$gradleVersion = if ($release -eq "25") { "9.8.1" } else { "8.5" } +$javaHome = [Environment]::GetEnvironmentVariable("JAVA${release}_HOME") +if (-not $javaHome -or -not [System.IO.Path]::IsPathFullyQualified($javaHome) -or + -not (Test-Path -LiteralPath $javaHome -PathType Container)) { + throw "JAVA${release}_HOME must identify the installed project JDK" +} + +$logDirectory = Join-Path (Get-Location).Path "test-results/toolchains/$PlanName" +New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null +$node = (& node -p "process.execPath" | Out-String).Trim() +if ($LASTEXITCODE -ne 0 -or -not [System.IO.Path]::IsPathFullyQualified($node)) { + throw "Could not resolve the setup-selected Node executable" +} +$gradle = (Get-Command gradle -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source +$env:JAVA_HOME = $javaHome + +$nodeVersion = (& $node --version 2>&1 | Out-String).Trim() +$nodeExitCode = $LASTEXITCODE +Set-Content (Join-Path $logDirectory "ci-node-version.log") $nodeVersion +if ($nodeExitCode -ne 0 -or $nodeVersion -notmatch '^v22\.') { + throw "Expected setup-selected Node 22 at ${node}, got: $nodeVersion" +} +$gradleInfo = (& $gradle --version 2>&1 | Out-String).Trim() +$gradleExitCode = $LASTEXITCODE +Set-Content (Join-Path $logDirectory "ci-gradle-version.log") $gradleInfo +if ($gradleExitCode -ne 0 -or $gradleInfo -notmatch "(?m)^Gradle $([regex]::Escape($gradleVersion))\r?$") { + throw "Expected Gradle $gradleVersion at ${gradle}, got: $gradleInfo" +} + +$quoteReplacement = if ($IsWindows) { "''" } else { "'" + '"' + "'" + '"' + "'" } +$arguments = @($node, "verify.mjs", $release, $gradleVersion, $gradle, $javaHome, $logDirectory) +$terminalCommand = ($arguments | ForEach-Object { "'" + $_.Replace("'", $quoteReplacement) + "'" }) -join " " +if ($IsWindows) { + $terminalCommand = "& $terminalCommand" +} +$action = "executeVSCodeCommand workbench.action.terminal.sendSequence " + + (@{ text = $terminalCommand + "`r" } | ConvertTo-Json -Compress) +$originalAction = 'executeVSCodeCommand workbench.action.terminal.sendSequence {"text":"node verify.mjs ' + + "$release $gradleVersion" + '\u000d"}' +$planFile = Join-Path "test-plans" "$PlanName.yaml" +$content = Get-Content -LiteralPath $planFile -Raw +$originalValue = "'" + $originalAction + "'" +if (-not $content.Contains($originalValue)) { + throw "Could not find the expected build helper command in $planFile" +} +# Escape YAML independently of the terminal shell and its JSON command arguments. +$boundValue = "'" + $action.Replace("'", "''") + "'" +Set-Content -LiteralPath $planFile -Value $content.Replace($originalValue, $boundValue) + +@{ + node = $node + nodeVersion = $nodeVersion + gradle = $gradle + gradleVersion = $gradleVersion + javaHome = $javaHome + javaRelease = $release + terminalCommand = $terminalCommand +} | ConvertTo-Json | Set-Content (Join-Path $logDirectory "ci-toolchain.json") +Write-Host "Bound Node $nodeVersion at $node" +Write-Host "Bound Gradle $gradleVersion at $gradle with JDK $release at $javaHome" +Write-Host "Toolchain diagnostics: $logDirectory" diff --git a/.github/skills/uitest/SKILL.md b/.github/skills/uitest/SKILL.md index 0f0534ef..246f5dca 100644 --- a/.github/skills/uitest/SKILL.md +++ b/.github/skills/uitest/SKILL.md @@ -80,3 +80,5 @@ Use workflow dispatch when the user asks to validate in CI or across platforms. For manual runs with `vsix_urls`, `prepare-bits` resolves each release/run once, downloads the Windows x64, Linux x64, and macOS ARM64 VSIX files, and uploads platform artifacts. Matrix jobs reuse those bits alongside the branch-built `pack-vsix`; they do not query release APIs. Preparation or missing-platform failures block the matrix rather than silently testing Marketplace bits. Runs without supplied URLs skip preparation and keep the existing Marketplace behavior. Release API requests use `GITHUB_TOKEN`. Cross-repository Actions artifacts use `VSCODE_JAVA_ARTIFACT_TOKEN` when configured, otherwise `GITHUB_TOKEN`; the token needs `actions:read` access to the source repository. Direct VSIX URLs are downloaded once and distributed unchanged, without GitHub API credentials. + +For `java-gradle` and `java-gradle-java25`, CI validates the setup-selected Node 22 and Gradle versions, then rewrites the build terminal command with absolute Node, Gradle, JDK and diagnostic paths. This avoids tools being reselected by terminal shell initialization. The helper keeps its version, program-output and bytecode assertions, and writes toolchain identity, command output and uncaught failures under `test-results/toolchains//` for artifact upload. Local two-argument helper invocations retain their existing PATH/JDK-environment behavior; GitHub Actions requires explicit paths. diff --git a/.github/workflows/e2e-autotest.yml b/.github/workflows/e2e-autotest.yml index 06ee5828..b976e299 100644 --- a/.github/workflows/e2e-autotest.yml +++ b/.github/workflows/e2e-autotest.yml @@ -234,6 +234,17 @@ jobs: with: gradle-version: ${{ matrix.plan == 'java-gradle-java25' && '9.8.1' || '8.5' }} + - name: Test Gradle toolchain binding + if: matrix.plan == 'java-gradle' || matrix.plan == 'java-gradle-java25' + run: node --test tests/gradle-toolchain.test.js + + - name: Bind CI toolchain for Gradle import tests + if: matrix.plan == 'java-gradle' || matrix.plan == 'java-gradle-java25' + shell: pwsh + env: + GRADLE_TEST_PLAN: ${{ matrix.plan }} + run: ./.github/scripts/bind-gradle-toolchain.ps1 -PlanName $env:GRADLE_TEST_PLAN + - name: Install resolved AutoTest CLI run: | npm install -g "@vscjava/vscode-autotest@${{ needs.discover.outputs.autotest_version }}" diff --git a/test-fixtures/gradle-import/verify.mjs b/test-fixtures/gradle-import/verify.mjs index 981b7b45..cd5b0651 100644 --- a/test-fixtures/gradle-import/verify.mjs +++ b/test-fixtures/gradle-import/verify.mjs @@ -5,26 +5,49 @@ import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; const workspace = path.dirname(fileURLToPath(import.meta.url)); -const [release, gradleVersion] = process.argv.slice(2); +const [release, gradleVersion, boundGradle, boundJavaHome, boundLogDirectory] = process.argv.slice(2); assert.ok(release === "21" || release === "25", "Expected Java 21 or Java 25"); assert.equal(gradleVersion, release === "21" ? "8.5" : "9.8.1", "Unexpected Gradle/JDK pair"); -const javaHome = process.env[`JAVA${release}_HOME`]; +if (process.env.GITHUB_ACTIONS === "true") { + assert.ok(boundGradle && boundJavaHome && boundLogDirectory, "CI requires explicit Gradle, JDK and diagnostic paths"); +} +for (const location of [boundGradle, boundJavaHome, boundLogDirectory]) { + if (location !== undefined) assert.ok(path.isAbsolute(location), `Expected an absolute toolchain path: ${location}`); +} +const javaHome = boundJavaHome ?? process.env[`JAVA${release}_HOME`]; assert.ok(javaHome, `Set JAVA${release}_HOME to the project JDK`); +const gradleExecutable = boundGradle ?? "gradle"; const env = { ...process.env, JAVA_HOME: javaHome }; const pathKey = Object.keys(env).find(key => key.toLowerCase() === "path") ?? "PATH"; env[pathKey] = `${path.join(javaHome, "bin")}${path.delimiter}${env[pathKey] ?? ""}`; const java = path.join(javaHome, "bin", process.platform === "win32" ? "java.exe" : "java"); -const logDirectory = path.join(workspace, ".autotest"); +const logDirectory = boundLogDirectory ?? path.join(workspace, ".autotest"); mkdirSync(logDirectory, { recursive: true }); +writeFileSync(path.join(logDirectory, "toolchain.json"), JSON.stringify({ + node: process.execPath, + nodeVersion: process.version, + gradle: gradleExecutable, + gradleVersion, + javaHome, + javaRelease: release, + workspace, +}, null, 2)); +process.on("uncaughtExceptionMonitor", error => { + writeFileSync(path.join(logDirectory, "failure.log"), error.stack ?? String(error)); +}); +console.log(`Node ${process.version}: ${process.execPath}`); +console.log(`Gradle ${gradleVersion}: ${gradleExecutable}`); +console.log(`JDK ${release}: ${javaHome}`); -function run(command, args, logName) { +function run(command, args, logName, windowsVerbatimArguments = false) { const result = spawnSync(command, args, { cwd: workspace, env, encoding: "utf8", timeout: 240_000, maxBuffer: 16 * 1024 * 1024, + windowsVerbatimArguments, }); const output = `${result.stdout ?? ""}${result.stderr ?? ""}`; writeFileSync(path.join(logDirectory, logName), output); @@ -35,8 +58,8 @@ function run(command, args, logName) { function gradle(args, logName) { return process.platform === "win32" - ? run(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", `gradle ${args.join(" ")}`], logName) - : run("gradle", args, logName); + ? run(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", `""${gradleExecutable}" ${args.join(" ")}"`], logName, true) + : run(gradleExecutable, args, logName); } const javaVersion = run(java, ["-XshowSettings:properties", "-version"], "java-version.log"); diff --git a/test-plans/java-gradle-java25.yaml b/test-plans/java-gradle-java25.yaml index f5d7ee2b..a585d1d3 100644 --- a/test-plans/java-gradle-java25.yaml +++ b/test-plans/java-gradle-java25.yaml @@ -1,5 +1,6 @@ # Gradle 9.8.1 compatibility: the language server, Gradle and project use JDK 25. # Prerequisites: JDK 25, JAVA25_HOME, and Gradle 9.8.1 on PATH (provisioned by CI). +# CI binds absolute Node/Gradle/JDK paths and saves diagnostics in test-results. # Explicit JDK selection is intentional; this does not test automatic fallback. name: "Gradle Java 25 — Import, Dependency Completion and Build" diff --git a/test-plans/java-gradle.yaml b/test-plans/java-gradle.yaml index 99696308..9bd33ff1 100644 --- a/test-plans/java-gradle.yaml +++ b/test-plans/java-gradle.yaml @@ -1,6 +1,7 @@ # Gradle 8.5 compatibility: the importer/daemon and project use JDK 21. # The language server uses redhat.java's bundled JDK, independently of Gradle. # Prerequisites: JDK 21, JAVA21_HOME, and Gradle 8.5 on PATH (provisioned by CI). +# CI binds absolute Node/Gradle/JDK paths and saves diagnostics in test-results. # Explicit JDK selection is intentional; this does not test automatic fallback. name: "Java Gradle Project — Gradle 8.5 with JDK 21" diff --git a/tests/gradle-toolchain.test.js b/tests/gradle-toolchain.test.js new file mode 100644 index 00000000..abca7b04 --- /dev/null +++ b/tests/gradle-toolchain.test.js @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); + +const filename = path.join(__dirname, "..", "test-fixtures", "gradle-import", "verify.mjs"); +const source = fs.readFileSync(filename, "utf8").replace(/^import .+;\r?\n/gm, "").replace("import.meta.url", "fixtureUrl"); + +function execute(platform, release = "21", options = {}) { + const paths = platform === "win32" ? path.win32 : path.posix; + const root = platform === "win32" ? "C:\\CI Tools" : "/ci/tools with spaces"; + const workspace = paths.join(root, "workspace"); + const javaHome = paths.join(root, `jdk-${release}`); + const gradle = paths.join(root, "gradle", "bin", platform === "win32" ? "gradle.bat" : "gradle"); + const logDirectory = paths.join(root, "results", "toolchain"); + const gradleVersion = release === "21" ? "8.5" : "9.8.1"; + const calls = []; + const files = new Map(); + const messages = []; + let monitor; + const bytecode = Buffer.alloc(8); + bytecode.writeUInt32BE(0xcafebabe); + bytecode.writeUInt16BE(options.bytecodeMajor ?? Number(release) + 44, 6); + const bound = options.bound !== false; + const argv = ["node", filename, release, gradleVersion]; + if (bound) argv.push(options.gradle ?? gradle, javaHome, logDirectory); + const state = { + paths, gradle, javaHome, logDirectory, calls, files, messages, + run: () => { + try { + vm.runInNewContext(source, { + assert, path: paths, fixtureUrl: "file:///verify.mjs", + fileURLToPath: () => paths.join(workspace, "verify.mjs"), + mkdirSync: () => {}, + writeFileSync: (location, content) => files.set(location, content), + readFileSync: () => bytecode, + console: { log: message => messages.push(message) }, + process: { + argv, platform, execPath: paths.join(root, "node"), version: "v22.23.2", + env: { + PATH: paths.join(root, "wrong-tools"), + JAVA_HOME: paths.join(root, "wrong-jdk"), + [`JAVA${release}_HOME`]: bound ? paths.join(root, "wrong-jdk") : javaHome, + GITHUB_ACTIONS: options.ci === false ? undefined : "true", + }, + on: (event, callback) => { if (event === "uncaughtExceptionMonitor") monitor = callback; }, + }, + spawnSync: (command, args, spawnOptions) => { + calls.push({ command, args, options: spawnOptions }); + if (calls.length === 1) { + return { status: 0, stdout: "", stderr: ` java.specification.version = ${options.javaRelease ?? release}\n` }; + } + if (calls.length === 2) { + return { status: 0, stdout: `Gradle ${options.gradleVersion ?? gradleVersion}\n`, stderr: "" }; + } + if (options.spawnError) { + return { error: options.spawnError, stdout: "", stderr: "Could not start Gradle\n" }; + } + return { + status: options.buildStatus ?? 0, + stdout: options.output ?? `GRADLE_PROJECT:${release}:GRADLE\n`, + stderr: options.stderr ?? "", + }; + }, + }, { filename }); + } catch (error) { + if (monitor) monitor(error); + throw error; + } + }, + }; + return state; +} + +for (const platform of ["win32", "linux", "darwin"]) { + for (const release of ["21", "25"]) { + test(`${platform} Java ${release} uses bound tools despite conflicting PATH and JDK environment`, () => { + const s = execute(platform, release); + s.run(); + assert.equal(s.calls[0].command, s.paths.join(s.javaHome, "bin", platform === "win32" ? "java.exe" : "java")); + assert.equal(s.calls[0].options.env.JAVA_HOME, s.javaHome); + assert.equal(s.calls[2].options.env.JAVA_HOME, s.javaHome); + if (platform === "win32") { + assert.equal(s.calls[1].command, "cmd.exe"); + assert.equal(s.calls[1].args[3], `""${s.gradle}" --version"`); + assert.equal(s.calls[1].options.windowsVerbatimArguments, true); + } else { + assert.equal(s.calls[1].command, s.gradle); + assert.equal(s.calls[2].command, s.gradle); + } + const identity = JSON.parse(s.files.get(s.paths.join(s.logDirectory, "toolchain.json"))); + assert.equal(identity.gradle, s.gradle); + assert.equal(identity.javaHome, s.javaHome); + assert.ok(s.files.has(s.paths.join(s.logDirectory, "build-and-run.log"))); + assert.ok(s.messages.includes(`GRADLE_JAVA${release}_BUILD_PASSED`)); + }); + } +} + +test("CI cannot silently fall back to PATH without explicit toolchain arguments", () => { + const s = execute("darwin", "21", { bound: false }); + assert.throws(s.run, /CI requires explicit Gradle, JDK and diagnostic paths/); + assert.equal(s.calls.length, 0); +}); + +test("relative bound executable paths are rejected before any command runs", () => { + const s = execute("linux", "21", { gradle: "gradle" }); + assert.throws(s.run, /Expected an absolute toolchain path/); + assert.equal(s.calls.length, 0); +}); + +test("local two-argument calls preserve PATH Gradle and project JDK environment behavior", () => { + const s = execute("linux", "21", { bound: false, ci: false }); + s.run(); + assert.equal(s.calls[1].command, "gradle"); + assert.equal(s.calls[0].options.env.JAVA_HOME, s.javaHome); +}); + +for (const [name, options, expected] of [ + ["wrong Gradle version", { gradleVersion: "9.7.1" }, /Gradle 9\.7\.1/], + ["wrong JDK release", { javaRelease: "25" }, /java\.specification\.version = 25/], + ["failed build", { buildStatus: 1, stderr: "Build failed\n" }, /Build failed/], + ["wrong program output", { output: "Unexpected output\n" }, /Unexpected output/], + ["wrong bytecode", { bytecodeMajor: 69 }, /Wrong target Java bytecode/], + ["spawn failure", { spawnError: new Error("ENOENT") }, /ENOENT/], +]) { + test(`${name} remains a failure and preserves diagnostics outside the temporary workspace`, () => { + const s = execute("darwin", "21", options); + assert.throws(s.run, expected); + assert.ok(s.files.has(s.paths.join(s.logDirectory, "toolchain.json"))); + assert.match(s.files.get(s.paths.join(s.logDirectory, "failure.log")), expected); + assert.ok(!s.messages.includes("GRADLE_JAVA21_BUILD_PASSED")); + }); +} From 6139ee97715e6271ba48a773acadaf723b6627f6 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Fri, 9 Oct 2026 09:46:24 +0800 Subject: [PATCH 10/15] test: repair Gradle model configuration and verify IDE health --- .github/scripts/bind-gradle-toolchain.ps1 | 21 +++++++ .github/skills/uitest/SKILL.md | 2 + .github/workflows/e2e-autotest.yml | 26 ++++++++ test-fixtures/gradle-import/README.md | 16 +++-- test-fixtures/gradle-import/build.gradle | 1 + test-fixtures/gradle-import/verify.mjs | 5 ++ test-plans/java-gradle-java25.yaml | 75 +++++++++++++++++++++-- test-plans/java-gradle.yaml | 75 +++++++++++++++++++++-- tests/gradle-toolchain.test.js | 28 +++++++++ 9 files changed, 237 insertions(+), 12 deletions(-) diff --git a/.github/scripts/bind-gradle-toolchain.ps1 b/.github/scripts/bind-gradle-toolchain.ps1 index b456a59d..72e0258b 100644 --- a/.github/scripts/bind-gradle-toolchain.ps1 +++ b/.github/scripts/bind-gradle-toolchain.ps1 @@ -22,7 +22,12 @@ if ($LASTEXITCODE -ne 0 -or -not [System.IO.Path]::IsPathFullyQualified($node)) throw "Could not resolve the setup-selected Node executable" } $gradle = (Get-Command gradle -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source +$gradleHome = Split-Path (Split-Path $gradle -Parent) -Parent +if (-not (Test-Path -LiteralPath (Join-Path $gradleHome "lib") -PathType Container)) { + throw "The selected Gradle executable must belong to a Gradle installation: $gradle" +} $env:JAVA_HOME = $javaHome +$env:ORG_GRADLE_PROJECT_javaRelease = $release $nodeVersion = (& $node --version 2>&1 | Out-String).Trim() $nodeExitCode = $LASTEXITCODE @@ -49,6 +54,12 @@ $originalAction = 'executeVSCodeCommand workbench.action.terminal.sendSequence { "$release $gradleVersion" + '\u000d"}' $planFile = Join-Path "test-plans" "$PlanName.yaml" $content = Get-Content -LiteralPath $planFile -Raw +$versionSetting = ' java.import.gradle.version: "' + $gradleVersion + '"' +if (-not $content.Contains($versionSetting)) { + throw "Could not find the expected Gradle version setting in $planFile" +} +$content = $content.Replace($versionSetting, + $versionSetting + "`n java.import.gradle.home: " + ($gradleHome | ConvertTo-Json -Compress)) $originalValue = "'" + $originalAction + "'" if (-not $content.Contains($originalValue)) { throw "Could not find the expected build helper command in $planFile" @@ -57,15 +68,25 @@ if (-not $content.Contains($originalValue)) { $boundValue = "'" + $action.Replace("'", "''") + "'" Set-Content -LiteralPath $planFile -Value $content.Replace($originalValue, $boundValue) +if ($env:GITHUB_ACTIONS -eq "true") { + if (-not $env:GITHUB_ENV) { + throw "GITHUB_ENV is required to share the project release with VS Code and Gradle" + } + "ORG_GRADLE_PROJECT_javaRelease=$release" >> $env:GITHUB_ENV +} + @{ node = $node nodeVersion = $nodeVersion gradle = $gradle gradleVersion = $gradleVersion + gradleHome = $gradleHome javaHome = $javaHome javaRelease = $release + projectJavaRelease = $env:ORG_GRADLE_PROJECT_javaRelease terminalCommand = $terminalCommand } | ConvertTo-Json | Set-Content (Join-Path $logDirectory "ci-toolchain.json") Write-Host "Bound Node $nodeVersion at $node" Write-Host "Bound Gradle $gradleVersion at $gradle with JDK $release at $javaHome" +Write-Host "Bound IDE Gradle home: $gradleHome; shared project release: $release" Write-Host "Toolchain diagnostics: $logDirectory" diff --git a/.github/skills/uitest/SKILL.md b/.github/skills/uitest/SKILL.md index 246f5dca..8ec12779 100644 --- a/.github/skills/uitest/SKILL.md +++ b/.github/skills/uitest/SKILL.md @@ -82,3 +82,5 @@ For manual runs with `vsix_urls`, `prepare-bits` resolves each release/run once, Release API requests use `GITHUB_TOKEN`. Cross-repository Actions artifacts use `VSCODE_JAVA_ARTIFACT_TOKEN` when configured, otherwise `GITHUB_TOKEN`; the token needs `actions:read` access to the source repository. Direct VSIX URLs are downloaded once and distributed unchanged, without GitHub API credentials. For `java-gradle` and `java-gradle-java25`, CI validates the setup-selected Node 22 and Gradle versions, then rewrites the build terminal command with absolute Node, Gradle, JDK and diagnostic paths. This avoids tools being reselected by terminal shell initialization. The helper keeps its version, program-output and bytecode assertions, and writes toolchain identity, command output and uncaught failures under `test-results/toolchains//` for artifact upload. Local two-argument helper invocations retain their existing PATH/JDK-environment behavior; GitHub Actions requires explicit paths. + +The same binding sets the IDE's `java.import.gradle.home` and exports `ORG_GRADLE_PROJECT_javaRelease` before VS Code starts. Local runs must set that project property as documented in the fixture README. These plans require BSP import, actual Gradle `build`/`run` tasks, and the exact configuration marker in Gradle for Java output; terminal success alone is insufficient. CI also preserves Gradle/Java output-channel and extension-host logs under `test-results/toolchains//ide/`. diff --git a/.github/workflows/e2e-autotest.yml b/.github/workflows/e2e-autotest.yml index b976e299..9cdaccac 100644 --- a/.github/workflows/e2e-autotest.yml +++ b/.github/workflows/e2e-autotest.yml @@ -342,6 +342,32 @@ jobs: Write-Host "Running: autotest $($autotestArgs -join ' ')" & autotest @autotestArgs + - name: Collect Gradle IDE diagnostics + if: ${{ always() && (matrix.plan == 'java-gradle' || matrix.plan == 'java-gradle-java25') }} + shell: pwsh + env: + GRADLE_TEST_PLAN: ${{ matrix.plan }} + run: | + $source = Join-Path (Get-Location).Path ".vscode-test/user-data/logs" + $destination = "test-results/toolchains/$env:GRADLE_TEST_PLAN/ide" + if (-not (Test-Path -LiteralPath $source -PathType Container)) { + Write-Warning "VS Code log directory is unavailable: $source" + } else { + $files = @(Get-ChildItem -LiteralPath $source -File -Recurse | Where-Object { + $_.Name -eq "exthost.log" -or $_.Name -like "*Gradle*.log" -or + $_.Name -like "*Language Support for Java*.log" + }) + if ($files.Count -eq 0) { + Write-Warning "No Gradle, Java output-channel or extension-host logs found in $source" + } + foreach ($file in $files) { + $target = Join-Path $destination ([System.IO.Path]::GetRelativePath($source, $file.FullName)) + New-Item -ItemType Directory -Path (Split-Path $target -Parent) -Force | Out-Null + Copy-Item -LiteralPath $file.FullName -Destination $target + } + Write-Host "Preserved $($files.Count) IDE diagnostic logs in $destination" + } + - name: Upload results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/test-fixtures/gradle-import/README.md b/test-fixtures/gradle-import/README.md index ec61bd3f..b67ad238 100644 --- a/test-fixtures/gradle-import/README.md +++ b/test-fixtures/gradle-import/README.md @@ -10,15 +10,23 @@ Configuration fails if the importer uses the wrong Gradle/JDK pair. The plans intentionally do not test wrapper precedence or automatic compatible-JDK selection; explicit settings avoid conflating those behaviors with import coverage. -Successful IDE import requires freshly generated Gradle project/classpath metadata -(Buildship or the Gradle Build Server importer), +Successful IDE import requires freshly generated BSP project/classpath metadata, the expected compiler source level, and completion of a Commons Lang API not present in the fixture source text. Generic keyword completion is not sufficient. -The temporary invalid completion probe is removed and saved before building. +The plans also require real `build` and `run` tasks in the Gradle Projects view and +the expected configuration marker in Gradle for Java output, without task-model errors. +The temporary invalid completion probe is explicitly reloaded into the editor, +then removed and saved before building. CI installs the matching Gradle CLI and preserves `JAVA21_HOME`/`JAVA25_HOME`. +It binds `java.import.gradle.home` to the same installation and exports +`ORG_GRADLE_PROJECT_javaRelease` before launching VS Code. The Java import arguments +alone do not configure the Gradle extension's separate task-model requests. For local runs, set the applicable variable, put that Gradle version on `PATH`, -and update the plan's JDK paths for your installation. The terminal verifier runs +set `ORG_GRADLE_PROJECT_javaRelease` to `21` or `25`, and update the plan's JDK paths +for your installation. The terminal verifier runs a clean build, checks the actual Gradle and Java versions, program output and class file version, and emits its success marker only after all checks pass. Generated IDE metadata and build logs are not fixture inputs. +CI preserves Gradle/Java output-channel and extension-host logs alongside the terminal +diagnostics so BSP transport failures cannot be inferred from JDT logs alone. diff --git a/test-fixtures/gradle-import/build.gradle b/test-fixtures/gradle-import/build.gradle index f9698456..c940ef02 100644 --- a/test-fixtures/gradle-import/build.gradle +++ b/test-fixtures/gradle-import/build.gradle @@ -11,6 +11,7 @@ if (expectedGradle == null) { if (gradle.gradleVersion != expectedGradle || JavaVersion.current().majorVersion != javaRelease.toString()) { throw new GradleException("Expected Gradle ${expectedGradle}/Java ${javaRelease}, got Gradle ${gradle.gradleVersion}/Java ${JavaVersion.current()}") } +logger.lifecycle("GRADLE_MODEL:${gradle.gradleVersion}:${javaRelease}:${JavaVersion.current().majorVersion}") java { toolchain { diff --git a/test-fixtures/gradle-import/verify.mjs b/test-fixtures/gradle-import/verify.mjs index cd5b0651..1282e1c6 100644 --- a/test-fixtures/gradle-import/verify.mjs +++ b/test-fixtures/gradle-import/verify.mjs @@ -31,11 +31,16 @@ writeFileSync(path.join(logDirectory, "toolchain.json"), JSON.stringify({ gradleVersion, javaHome, javaRelease: release, + projectJavaRelease: process.env.ORG_GRADLE_PROJECT_javaRelease ?? null, workspace, }, null, 2)); process.on("uncaughtExceptionMonitor", error => { writeFileSync(path.join(logDirectory, "failure.log"), error.stack ?? String(error)); }); +if (process.env.GITHUB_ACTIONS === "true") { + assert.equal(process.env.ORG_GRADLE_PROJECT_javaRelease, release, + `CI requires javaRelease=${release} to be shared with the IDE, not only passed to the terminal`); +} console.log(`Node ${process.version}: ${process.execPath}`); console.log(`Gradle ${gradleVersion}: ${gradleExecutable}`); console.log(`JDK ${release}: ${javaHome}`); diff --git a/test-plans/java-gradle-java25.yaml b/test-plans/java-gradle-java25.yaml index a585d1d3..c5901a47 100644 --- a/test-plans/java-gradle-java25.yaml +++ b/test-plans/java-gradle-java25.yaml @@ -1,12 +1,12 @@ # Gradle 9.8.1 compatibility: the language server, Gradle and project use JDK 25. -# Prerequisites: JDK 25, JAVA25_HOME, and Gradle 9.8.1 on PATH (provisioned by CI). -# CI binds absolute Node/Gradle/JDK paths and saves diagnostics in test-results. +# Prerequisites: JDK 25, JAVA25_HOME, Gradle 9.8.1, ORG_GRADLE_PROJECT_javaRelease=25. +# CI binds the IDE/terminal toolchain and saves Gradle diagnostics in test-results. # Explicit JDK selection is intentional; this does not test automatic fallback. name: "Gradle Java 25 — Import, Dependency Completion and Build" description: | Import a real Java 25 project with Gradle 9.8.1 and a JDK 25 daemon. - Verify the Gradle model, Java 25 source level and dependency-backed completion, + Verify BSP import, the Gradle task model, Java 25 source level and dependency-backed completion, then build and run a Java 25 ScopedValue program with Java 25 bytecode. The incomplete completion probe may temporarily produce a diagnostic; it is removed and saved before the final zero-error and build checks. @@ -35,6 +35,9 @@ setup: java.import.gradle.arguments: - "-PjavaRelease=25" java.import.generatesMetadataFilesAtProjectRoot: true + java.gradle.buildServer.enabled: "on" + java.trace.server: "verbose" + gradle.debug: true steps: - id: "ls-ready" @@ -44,6 +47,46 @@ steps: timeout: 300 skipLlmVerify: true + - id: "show-gradle-task-model" + action: "executeVSCodeCommand gradleTasksView.focus" + verifyTreeItem: + name: "gradle-import-regression" + exact: true + inView: "Gradle Projects" + timeout: 300 + + - id: "expand-gradle-tasks" + action: "expandTreeItem Tasks" + verifyTreeItem: + name: "build" + exact: true + level: 3 + inView: "Gradle Projects" + + - id: "verify-gradle-build-task" + action: "expandTreeItem build" + verifyTreeItem: + name: "build" + exact: true + level: 4 + inView: "Gradle Projects" + + - id: "verify-gradle-run-task" + action: "expandTreeItem application" + verifyTreeItem: + name: "run" + exact: true + level: 4 + inView: "Gradle Projects" + + - id: "verify-gradle-model-toolchain" + action: "executeVSCodeCommand gradle.showLogs" + verifyOutputChannel: + channel: "Gradle for Java" + contains: "GRADLE_MODEL:9.8.1:25:25" + notContains: "Error getting build" + skipLlmVerify: true + - id: "verify-gradle-model" action: "open file GradleSmoke25.java" verifyFile: @@ -63,6 +106,13 @@ steps: errors: 0 timeout: 15 + - id: "verify-bsp-import" + action: "waitForLanguageServer" + verifyFile: + path: "~/.project" + contains: "com.microsoft.gradle.bs.importer.GradleBuildServerProjectNature" + skipLlmVerify: true + - id: "verify-gradle-classpath" action: "saveFile" verifyFile: @@ -77,6 +127,15 @@ steps: contains: "String probe = StringUtils.cap;" skipLlmVerify: true + - id: "reload-dependency-probe" + action: "executeVSCodeCommand workbench.action.files.revert" + verifyEditorTab: + title: "GradleSmoke25.java" + active: true + verifyEditor: + contains: "String probe = StringUtils.cap;" + skipLlmVerify: true + - id: "select-completion-line" action: "goToLine 8" verifyEditorTab: @@ -104,7 +163,7 @@ steps: action: "executeVSCodeCommand editor.action.deleteLines" - id: "save-source" - action: "saveFile" + action: "executeVSCodeCommand workbench.action.files.save" verifyFile: path: "~/src/java25/example/GradleSmoke25.java" matches: '^(?![\s\S]*String probe = StringUtils\.cap;)' @@ -122,3 +181,11 @@ steps: errors: 0 timeout: 300 skipLlmVerify: true + + - id: "verify-gradle-model-remains-healthy" + action: "executeVSCodeCommand gradle.showLogs" + verifyOutputChannel: + channel: "Gradle for Java" + contains: "GRADLE_MODEL:9.8.1:25:25" + notContains: "Error getting build" + skipLlmVerify: true diff --git a/test-plans/java-gradle.yaml b/test-plans/java-gradle.yaml index 9bd33ff1..96104d61 100644 --- a/test-plans/java-gradle.yaml +++ b/test-plans/java-gradle.yaml @@ -1,13 +1,13 @@ # Gradle 8.5 compatibility: the importer/daemon and project use JDK 21. # The language server uses redhat.java's bundled JDK, independently of Gradle. -# Prerequisites: JDK 21, JAVA21_HOME, and Gradle 8.5 on PATH (provisioned by CI). -# CI binds absolute Node/Gradle/JDK paths and saves diagnostics in test-results. +# Prerequisites: JDK 21, JAVA21_HOME, Gradle 8.5, ORG_GRADLE_PROJECT_javaRelease=21. +# CI binds the IDE/terminal toolchain and saves Gradle diagnostics in test-results. # Explicit JDK selection is intentional; this does not test automatic fallback. name: "Java Gradle Project — Gradle 8.5 with JDK 21" description: | Import a Gradle 8.5 project using JDK 21 for the Gradle daemon. - Verify the Gradle model, Java 21 source level and dependency-backed completion, + Verify BSP import, the Gradle task model, Java 21 source level and dependency-backed completion, then build and run the saved project with Java 21 bytecode. The incomplete completion probe may temporarily produce a diagnostic; it is removed and saved before the final zero-error and build checks. @@ -35,6 +35,9 @@ setup: java.import.gradle.arguments: - "-PjavaRelease=21" java.import.generatesMetadataFilesAtProjectRoot: true + java.gradle.buildServer.enabled: "on" + java.trace.server: "verbose" + gradle.debug: true steps: - id: "ls-ready" @@ -44,6 +47,46 @@ steps: timeout: 300 skipLlmVerify: true + - id: "show-gradle-task-model" + action: "executeVSCodeCommand gradleTasksView.focus" + verifyTreeItem: + name: "gradle-import-regression" + exact: true + inView: "Gradle Projects" + timeout: 300 + + - id: "expand-gradle-tasks" + action: "expandTreeItem Tasks" + verifyTreeItem: + name: "build" + exact: true + level: 3 + inView: "Gradle Projects" + + - id: "verify-gradle-build-task" + action: "expandTreeItem build" + verifyTreeItem: + name: "build" + exact: true + level: 4 + inView: "Gradle Projects" + + - id: "verify-gradle-run-task" + action: "expandTreeItem application" + verifyTreeItem: + name: "run" + exact: true + level: 4 + inView: "Gradle Projects" + + - id: "verify-gradle-model-toolchain" + action: "executeVSCodeCommand gradle.showLogs" + verifyOutputChannel: + channel: "Gradle for Java" + contains: "GRADLE_MODEL:8.5:21:21" + notContains: "Error getting build" + skipLlmVerify: true + - id: "verify-gradle-model" action: "open file GradleSmoke21.java" verifyFile: @@ -63,6 +106,13 @@ steps: errors: 0 timeout: 15 + - id: "verify-bsp-import" + action: "waitForLanguageServer" + verifyFile: + path: "~/.project" + contains: "com.microsoft.gradle.bs.importer.GradleBuildServerProjectNature" + skipLlmVerify: true + - id: "verify-gradle-classpath" action: "saveFile" verifyFile: @@ -79,6 +129,15 @@ steps: contains: "String probe = StringUtils.cap;" skipLlmVerify: true + - id: "reload-dependency-probe" + action: "executeVSCodeCommand workbench.action.files.revert" + verifyEditorTab: + title: "GradleSmoke21.java" + active: true + verifyEditor: + contains: "String probe = StringUtils.cap;" + skipLlmVerify: true + - id: "select-completion-line" action: "goToLine 8" verifyEditorTab: @@ -106,7 +165,7 @@ steps: action: "executeVSCodeCommand editor.action.deleteLines" - id: "save-source" - action: "saveFile" + action: "executeVSCodeCommand workbench.action.files.save" verifyFile: path: "~/src/java21/example/GradleSmoke21.java" matches: '^(?![\s\S]*String probe = StringUtils\.cap;)' @@ -124,3 +183,11 @@ steps: errors: 0 timeout: 300 skipLlmVerify: true + + - id: "verify-gradle-model-remains-healthy" + action: "executeVSCodeCommand gradle.showLogs" + verifyOutputChannel: + channel: "Gradle for Java" + contains: "GRADLE_MODEL:8.5:21:21" + notContains: "Error getting build" + skipLlmVerify: true diff --git a/tests/gradle-toolchain.test.js b/tests/gradle-toolchain.test.js index abca7b04..05851f68 100644 --- a/tests/gradle-toolchain.test.js +++ b/tests/gradle-toolchain.test.js @@ -46,6 +46,7 @@ function execute(platform, release = "21", options = {}) { JAVA_HOME: paths.join(root, "wrong-jdk"), [`JAVA${release}_HOME`]: bound ? paths.join(root, "wrong-jdk") : javaHome, GITHUB_ACTIONS: options.ci === false ? undefined : "true", + ORG_GRADLE_PROJECT_javaRelease: "projectRelease" in options ? options.projectRelease : release, }, on: (event, callback) => { if (event === "uncaughtExceptionMonitor") monitor = callback; }, }, @@ -95,6 +96,7 @@ for (const platform of ["win32", "linux", "darwin"]) { const identity = JSON.parse(s.files.get(s.paths.join(s.logDirectory, "toolchain.json"))); assert.equal(identity.gradle, s.gradle); assert.equal(identity.javaHome, s.javaHome); + assert.equal(identity.projectJavaRelease, release); assert.ok(s.files.has(s.paths.join(s.logDirectory, "build-and-run.log"))); assert.ok(s.messages.includes(`GRADLE_JAVA${release}_BUILD_PASSED`)); }); @@ -113,6 +115,16 @@ test("relative bound executable paths are rejected before any command runs", () assert.equal(s.calls.length, 0); }); +for (const projectRelease of [undefined, "21"]) { + test(`Java 25 CI rejects a ${projectRelease === undefined ? "missing" : "conflicting"} shared project release`, () => { + const s = execute("darwin", "25", { projectRelease }); + assert.throws(s.run, /CI requires javaRelease=25 to be shared with the IDE/); + assert.equal(s.calls.length, 0); + assert.match(s.files.get(s.paths.join(s.logDirectory, "failure.log")), /CI requires javaRelease=25/); + assert.ok(!s.messages.includes("GRADLE_JAVA25_BUILD_PASSED")); + }); +} + test("local two-argument calls preserve PATH Gradle and project JDK environment behavior", () => { const s = execute("linux", "21", { bound: false, ci: false }); s.run(); @@ -136,3 +148,19 @@ for (const [name, options, expected] of [ assert.ok(!s.messages.includes("GRADLE_JAVA21_BUILD_PASSED")); }); } + +for (const plan of ["java-gradle", "java-gradle-java25"]) { + test(`${plan} verifies the reloaded probe before consuming completion`, () => { + const content = fs.readFileSync(path.join(__dirname, "..", "test-plans", `${plan}.yaml`), "utf8"); + const ids = [...content.matchAll(/^\s*- id: "([^"]+)"/gm)].map(match => match[1]); + assert.match(content, /java\.gradle\.buildServer\.enabled: "on"/); + assert.ok(ids.indexOf("ls-ready") < ids.indexOf("show-gradle-task-model")); + assert.ok(ids.indexOf("show-gradle-task-model") < ids.indexOf("expand-gradle-tasks")); + assert.ok(ids.indexOf("expand-gradle-tasks") < ids.indexOf("verify-gradle-build-task")); + assert.ok(ids.indexOf("insert-dependency-probe") < ids.indexOf("reload-dependency-probe")); + assert.ok(ids.indexOf("reload-dependency-probe") < ids.indexOf("verify-dependency-completion")); + assert.ok(ids.indexOf("remove-dependency-probe") < ids.indexOf("save-source")); + assert.ok(ids.indexOf("save-source") < ids.indexOf("verify-build-and-run")); + assert.ok(ids.indexOf("verify-gradle-model-remains-healthy") > ids.indexOf("verify-build-and-run")); + }); +} From 089d87fdaec2eb075108095b5f562f7d0b736afc Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Sat, 10 Oct 2026 16:37:37 +0800 Subject: [PATCH 11/15] chore: split CI preparation and navigation coverage into separate PRs --- .github/scripts/prepare-vsix.ps1 | 162 --------------- .github/scripts/test-prepare-vsix.ps1 | 172 --------------- .github/skills/uitest/SKILL.md | 4 - .github/workflows/e2e-autotest.yml | 195 ++++++++++++------ .../java-go-to-super-implementation.yaml | 46 +++-- 5 files changed, 164 insertions(+), 415 deletions(-) delete mode 100644 .github/scripts/prepare-vsix.ps1 delete mode 100644 .github/scripts/test-prepare-vsix.ps1 diff --git a/.github/scripts/prepare-vsix.ps1 b/.github/scripts/prepare-vsix.ps1 deleted file mode 100644 index c826150c..00000000 --- a/.github/scripts/prepare-vsix.ps1 +++ /dev/null @@ -1,162 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] - [string] $Sources, - [Parameter(Mandatory = $true)] - [string] $OutputDirectory -) - -$ErrorActionPreference = "Stop" -$ProgressPreference = "SilentlyContinue" -$platformIds = @("win32-x64", "linux-x64", "darwin-arm64") -$urls = @($Sources -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" } | Select-Object -Unique) -if ($urls.Count -eq 0) { - throw "No VSIX sources supplied" -} -if (Test-Path -LiteralPath $OutputDirectory) { - throw "VSIX output directory already exists: $OutputDirectory" -} - -$downloadCache = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) -$runCache = @{} -$cacheDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "java-pack-vsix-$([guid]::NewGuid())" -New-Item -ItemType Directory -Path $cacheDirectory | Out-Null - -function Get-GitHubHeaders { - param([switch] $Actions) - - $token = if ($Actions -and $env:VSCODE_JAVA_ARTIFACT_TOKEN) { $env:VSCODE_JAVA_ARTIFACT_TOKEN } else { $env:GITHUB_TOKEN } - if (-not $token) { - throw "GitHub API requests require GITHUB_TOKEN or, for Actions artifacts, VSCODE_JAVA_ARTIFACT_TOKEN" - } - return @{ - Accept = "application/vnd.github+json" - "X-GitHub-Api-Version" = "2022-11-28" - "User-Agent" = "vscode-java-pack-autotest" - Authorization = "Bearer $token" - } -} - -function Select-VsixForPlatform { - param( - [AllowEmptyCollection()] - [object[]] $VsixFiles, - [string] $PlatformId - ) - - $platformVsix = $VsixFiles | Where-Object { $_.Name -like "*-$PlatformId-*" } | Sort-Object Name | Select-Object -First 1 - if ($platformVsix) { - return $platformVsix - } - $universalVsix = $VsixFiles | Where-Object { $_.Name -notmatch '-(darwin|linux|win32)-' } | Sort-Object Name | Select-Object -First 1 - if ($universalVsix) { - return $universalVsix - } - throw "No matching VSIX found for platform $PlatformId. Available VSIX files: $($VsixFiles.Name -join ', ')" -} - -function Get-DownloadedVsix { - param([string] $Url) - - if (-not $downloadCache.ContainsKey($Url)) { - $uri = [uri] $Url - if (-not $uri.IsAbsoluteUri -or $uri.Scheme -notin @("https", "http")) { - throw "VSIX downloads require an absolute HTTP(S) URL" - } - $fileName = [System.IO.Path]::GetFileName([uri]::UnescapeDataString($uri.AbsolutePath)) - if (-not $fileName.EndsWith(".vsix", [System.StringComparison]::OrdinalIgnoreCase)) { - throw "VSIX download URL must name a .vsix file" - } - $directory = Join-Path $cacheDirectory "download-$($downloadCache.Count)" - New-Item -ItemType Directory -Path $directory | Out-Null - $filePath = Join-Path $directory $fileName - Write-Host "Downloading VSIX: $fileName" - # Direct and release-asset URLs must not receive GitHub API credentials. - Invoke-WebRequest -Uri $uri -OutFile $filePath -UseBasicParsing | Out-Null - $downloadCache.Add($Url, $filePath) - } - return Get-Item -LiteralPath $downloadCache[$Url] -} - -function Get-GitHubActionsRunVsix { - param([string] $Owner, [string] $Repo, [string] $RunId) - - $key = "$Owner/$Repo/$RunId" - if (-not $runCache.ContainsKey($key)) { - Write-Host "Resolving GitHub Actions run: $Owner/$Repo#$RunId" - $headers = Get-GitHubHeaders -Actions - $artifactsUrl = "https://api.github.com/repos/$Owner/$Repo/actions/runs/$RunId/artifacts?per_page=100" - $artifacts = @((Invoke-RestMethod -Uri $artifactsUrl -Headers $headers -UseBasicParsing).artifacts | Where-Object { -not $_.expired }) - if ($artifacts.Count -eq 0) { - throw "No non-expired artifacts found for GitHub Actions run $Owner/$Repo#$RunId" - } - $artifact = $artifacts | Where-Object { $_.name -eq "vscode-java" } | Select-Object -First 1 - if (-not $artifact -and $artifacts.Count -eq 1) { - $artifact = $artifacts[0] - } - if (-not $artifact) { - throw "No vscode-java artifact found for GitHub Actions run $Owner/$Repo#$RunId. Available artifacts: $($artifacts.name -join ', ')" - } - $archiveUri = [uri] $artifact.archive_download_url - if ($archiveUri.Scheme -ne "https" -or $archiveUri.Host -ne "api.github.com") { - throw "GitHub artifact archive URL must use https://api.github.com" - } - $directory = Join-Path $cacheDirectory "run-$($runCache.Count)" - $zipPath = "$directory.zip" - Write-Host "Downloading Actions artifact: $($artifact.name)" - try { - Invoke-WebRequest -Uri $archiveUri -OutFile $zipPath -Headers $headers -UseBasicParsing | Out-Null - } catch { - throw "Failed to download artifact $($artifact.name) from $Owner/$Repo#$RunId. Cross-repo downloads may require VSCODE_JAVA_ARTIFACT_TOKEN with actions:read access. $($_.Exception.Message)" - } - Expand-Archive -LiteralPath $zipPath -DestinationPath $directory -Force - $files = @(Get-ChildItem -LiteralPath $directory -Recurse -File -Filter "*.vsix") - if ($files.Count -eq 0) { - throw "Artifact $($artifact.name) from $Owner/$Repo#$RunId does not contain VSIX files" - } - $runCache[$key] = $files - } - return $runCache[$key] -} - -try { - foreach ($platformId in $platformIds) { - New-Item -ItemType Directory -Path (Join-Path $OutputDirectory $platformId) -Force | Out-Null - } - foreach ($url in $urls) { - if ($url -match '^https://github\.com/([^/]+)/([^/]+)/releases/tag/(.+)$') { - $owner = $Matches[1]; $repo = $Matches[2]; $tag = $Matches[3] - Write-Host "Resolving GitHub release: $owner/$repo@$tag" - $apiUrl = "https://api.github.com/repos/$owner/$repo/releases/tags/$tag" - $release = Invoke-RestMethod -Uri $apiUrl -Headers (Get-GitHubHeaders) -UseBasicParsing - $assets = @($release.assets | Where-Object { $_.name -like "*.vsix" }) - foreach ($platformId in $platformIds) { - $asset = Select-VsixForPlatform -VsixFiles $assets -PlatformId $platformId - $file = Get-DownloadedVsix -Url $asset.browser_download_url - Copy-Item -LiteralPath $file.FullName -Destination (Join-Path $OutputDirectory "$platformId/$($file.Name)") -Force - } - } elseif ($url -match '^https://github\.com/([^/]+)/([^/]+)/actions/runs/(\d+)') { - $files = @(Get-GitHubActionsRunVsix -Owner $Matches[1] -Repo $Matches[2] -RunId $Matches[3]) - foreach ($platformId in $platformIds) { - $file = Select-VsixForPlatform -VsixFiles $files -PlatformId $platformId - Copy-Item -LiteralPath $file.FullName -Destination (Join-Path $OutputDirectory "$platformId/$($file.Name)") -Force - } - } elseif ($url -match '^\d+$') { - $files = @(Get-GitHubActionsRunVsix -Owner "redhat-developer" -Repo "vscode-java" -RunId $url) - foreach ($platformId in $platformIds) { - $file = Select-VsixForPlatform -VsixFiles $files -PlatformId $platformId - Copy-Item -LiteralPath $file.FullName -Destination (Join-Path $OutputDirectory "$platformId/$($file.Name)") -Force - } - } else { - $file = Get-DownloadedVsix -Url $url - foreach ($platformId in $platformIds) { - Copy-Item -LiteralPath $file.FullName -Destination (Join-Path $OutputDirectory "$platformId/$($file.Name)") -Force - } - } - } - Get-ChildItem -LiteralPath $OutputDirectory -Recurse -File -Filter "*.vsix" | ForEach-Object { - Write-Host "Prepared $($_.Directory.Name): $($_.Name) ($([math]::Round($_.Length / 1MB, 1)) MB)" - } -} finally { - Remove-Item -LiteralPath $cacheDirectory -Recurse -Force -} diff --git a/.github/scripts/test-prepare-vsix.ps1 b/.github/scripts/test-prepare-vsix.ps1 deleted file mode 100644 index bf3b25df..00000000 --- a/.github/scripts/test-prepare-vsix.ps1 +++ /dev/null @@ -1,172 +0,0 @@ -$ErrorActionPreference = "Stop" -$scriptPath = Join-Path $PSScriptRoot "prepare-vsix.ps1" -$testDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "java-pack-vsix-tests-$([guid]::NewGuid())" -$previousToken = $env:GITHUB_TOKEN -$previousArtifactToken = $env:VSCODE_JAVA_ARTIFACT_TOKEN -$platformIds = @("win32-x64", "linux-x64", "darwin-arm64") -$testState = @{ - restCalls = [System.Collections.Generic.List[object]]::new() - webCalls = [System.Collections.Generic.List[object]]::new() - restFailure = "" - webFailure = "" - caseCount = 0 -} -$releaseUrl = "https://github.com/redhat-developer/vscode-java/releases/tag/v1.57.0" -$runUrl = "https://github.com/redhat-developer/vscode-java/actions/runs/123" -$testState.archiveFiles = @($platformIds | ForEach-Object { "java-$_-1.57.0.vsix" }) -$testState.assets = @($testState.archiveFiles | ForEach-Object { - [pscustomobject]@{ name = $_; browser_download_url = "https://downloads.example/$_" } -}) -$testState.artifacts = @([pscustomobject]@{ - name = "vscode-java" - expired = $false - archive_download_url = "https://api.github.com/repos/redhat-developer/vscode-java/actions/artifacts/1/zip" -}) - -function Assert-True { - param([bool] $Condition, [string] $Message) - if (-not $Condition) { - throw $Message - } -} - -function Invoke-RestMethod { - param([string] $Uri, [hashtable] $Headers, [switch] $UseBasicParsing) - $testState.restCalls.Add([pscustomobject]@{ Uri = $Uri; Headers = $Headers }) - if ($testState.restFailure) { - throw $testState.restFailure - } - if ($Uri -match '/releases/tags/') { - Assert-True ($Headers.Authorization -eq "Bearer $env:GITHUB_TOKEN") "Release metadata must use GITHUB_TOKEN" - return [pscustomobject]@{ assets = $testState.assets } - } - if ($Uri -match '/actions/runs/') { - $token = if ($env:VSCODE_JAVA_ARTIFACT_TOKEN) { $env:VSCODE_JAVA_ARTIFACT_TOKEN } else { $env:GITHUB_TOKEN } - Assert-True ($Headers.Authorization -eq "Bearer $token") "Actions metadata must use the artifact token or GITHUB_TOKEN" - return [pscustomobject]@{ artifacts = $testState.artifacts } - } - throw "Unexpected metadata request" -} - -function Invoke-WebRequest { - param([uri] $Uri, [string] $OutFile, [hashtable] $Headers, [switch] $UseBasicParsing) - $testState.webCalls.Add([pscustomobject]@{ Uri = $Uri; Headers = $Headers }) - if ($testState.webFailure) { - throw $testState.webFailure - } - if ($Uri.Host -eq "api.github.com") { - $token = if ($env:VSCODE_JAVA_ARTIFACT_TOKEN) { $env:VSCODE_JAVA_ARTIFACT_TOKEN } else { $env:GITHUB_TOKEN } - Assert-True ($Headers.Authorization -eq "Bearer $token") "Actions archive must use the artifact token or GITHUB_TOKEN" - } else { - Assert-True (-not $Headers) "Direct/release downloads must not receive API credentials" - } - [System.IO.File]::WriteAllText($OutFile, "fixture") -} - -function Expand-Archive { - param([string] $LiteralPath, [string] $DestinationPath, [switch] $Force) - New-Item -ItemType Directory -Path $DestinationPath -Force | Out-Null - foreach ($name in $testState.archiveFiles) { - [System.IO.File]::WriteAllText((Join-Path $DestinationPath $name), "fixture") - } -} - -function Invoke-TestPreparation { - param([string] $Sources, [string] $ExpectedError = "") - - $testState.caseCount++ - $testState.restCalls.Clear() - $testState.webCalls.Clear() - $output = Join-Path $testDirectory "case-$($testState.caseCount)" - try { - & $scriptPath -Sources $Sources -OutputDirectory $output 6> $null - } catch { - if (-not $ExpectedError -or $_.Exception.Message -notlike "*$ExpectedError*") { - throw - } - return $output - } - Assert-True (-not $ExpectedError) "Expected preparation failure: $ExpectedError" - return $output -} - -function Assert-PlatformFiles { - param([string] $Directory, [switch] $Universal) - foreach ($platformId in $platformIds) { - $files = @(Get-ChildItem -LiteralPath (Join-Path $Directory $platformId) -File) - $expected = if ($Universal) { "extension-1.0.vsix" } else { "java-$platformId-1.57.0.vsix" } - Assert-True ($files.Count -eq 1 -and $files[0].Name -eq $expected) "Wrong bits for $platformId" - } -} - -try { - New-Item -ItemType Directory -Path $testDirectory | Out-Null - $env:GITHUB_TOKEN = "test-github-token" - $env:VSCODE_JAVA_ARTIFACT_TOKEN = "test-artifact-token" - - $testState.assets += [pscustomobject]@{ name = "java-linux-arm64-1.57.0.vsix"; browser_download_url = "https://downloads.example/wrong.vsix" } - $testState.assets += [pscustomobject]@{ name = "extension-1.0.vsix"; browser_download_url = "https://downloads.example/extension-1.0.vsix" } - $output = Invoke-TestPreparation "$releaseUrl, $releaseUrl" - Assert-PlatformFiles $output - Assert-True ($testState.restCalls.Count -eq 1 -and $testState.webCalls.Count -eq 3) "Release must resolve once and download only the three required platform assets" - - $testState.assets = @([pscustomobject]@{ name = "extension-1.0.vsix"; browser_download_url = "https://downloads.example/extension-1.0.vsix" }) - $output = Invoke-TestPreparation $releaseUrl - Assert-PlatformFiles $output -Universal - Assert-True ($testState.restCalls.Count -eq 1 -and $testState.webCalls.Count -eq 1) "Universal release must download once" - - $output = Invoke-TestPreparation "$runUrl,123" - Assert-PlatformFiles $output - Assert-True ($testState.restCalls.Count -eq 1 -and $testState.webCalls.Count -eq 1) "Run URL and numeric alias must share one metadata/archive download" - - $env:VSCODE_JAVA_ARTIFACT_TOKEN = $null - $output = Invoke-TestPreparation $runUrl - Assert-PlatformFiles $output - Assert-True ($testState.restCalls.Count -eq 1 -and $testState.webCalls.Count -eq 1) "Actions fallback token must retain single-download behavior" - - $testState.artifacts[0].name = "sole-artifact" - $testState.archiveFiles = @("extension-1.0.vsix") - $output = Invoke-TestPreparation $runUrl - Assert-PlatformFiles $output -Universal - - $env:GITHUB_TOKEN = $null - $output = Invoke-TestPreparation " https://downloads.example/extension-1.0.vsix?download=1,https://downloads.example/extension-1.0.vsix?download=1 " - Assert-PlatformFiles $output -Universal - Assert-True ($testState.restCalls.Count -eq 0 -and $testState.webCalls.Count -eq 1) "Direct URLs must download once without API authentication" - - Invoke-TestPreparation $releaseUrl "GitHub API requests require" | Out-Null - Assert-True ($testState.restCalls.Count -eq 0) "Missing token must fail before calling the release API" - $env:GITHUB_TOKEN = "test-github-token" - - $testState.assets = @([pscustomobject]@{ name = "java-win32-x64-1.57.0.vsix"; browser_download_url = "https://downloads.example/java-win32-x64-1.57.0.vsix" }) - Invoke-TestPreparation $releaseUrl "No matching VSIX found for platform linux-x64" | Out-Null - $testState.assets = @() - Invoke-TestPreparation $releaseUrl "No matching VSIX found" | Out-Null - - $testState.restFailure = "rate limit exceeded" - Invoke-TestPreparation $releaseUrl "rate limit exceeded" | Out-Null - Assert-True ($testState.webCalls.Count -eq 0) "Metadata failure must not fall back to other bits" - $testState.restFailure = "" - - $testState.artifacts[0].expired = $true - Invoke-TestPreparation $runUrl "No non-expired artifacts" | Out-Null - $testState.artifacts[0].expired = $false - $testState.artifacts[0].archive_download_url = "https://downloads.example/archive.zip" - Invoke-TestPreparation $runUrl "archive URL must use https://api.github.com" | Out-Null - Assert-True ($testState.webCalls.Count -eq 0) "Archive credentials must not be sent to an external host" - $testState.artifacts[0].archive_download_url = "https://api.github.com/repos/redhat-developer/vscode-java/actions/artifacts/1/zip" - - $testState.archiveFiles = @() - Invoke-TestPreparation $runUrl "does not contain VSIX files" | Out-Null - $testState.webFailure = "forbidden" - Invoke-TestPreparation $runUrl "Cross-repo downloads may require VSCODE_JAVA_ARTIFACT_TOKEN" | Out-Null - $testState.webFailure = "" - - Invoke-TestPreparation "https://downloads.example/file.zip" "must name a .vsix file" | Out-Null - Invoke-TestPreparation " , " "No VSIX sources supplied" | Out-Null - Write-Host "Passed $($testState.caseCount) VSIX preparation tests." -} finally { - $env:GITHUB_TOKEN = $previousToken - $env:VSCODE_JAVA_ARTIFACT_TOKEN = $previousArtifactToken - Remove-Item -LiteralPath $testDirectory -Recurse -Force -} diff --git a/.github/skills/uitest/SKILL.md b/.github/skills/uitest/SKILL.md index 8ec12779..d16ab66e 100644 --- a/.github/skills/uitest/SKILL.md +++ b/.github/skills/uitest/SKILL.md @@ -77,10 +77,6 @@ The repository workflow `.github/workflows/e2e-autotest.yml` builds a branch VSI Use workflow dispatch when the user asks to validate in CI or across platforms. The `test_plan` input can target a single plan; leave it empty to run all plans. -For manual runs with `vsix_urls`, `prepare-bits` resolves each release/run once, downloads the Windows x64, Linux x64, and macOS ARM64 VSIX files, and uploads platform artifacts. Matrix jobs reuse those bits alongside the branch-built `pack-vsix`; they do not query release APIs. Preparation or missing-platform failures block the matrix rather than silently testing Marketplace bits. Runs without supplied URLs skip preparation and keep the existing Marketplace behavior. - -Release API requests use `GITHUB_TOKEN`. Cross-repository Actions artifacts use `VSCODE_JAVA_ARTIFACT_TOKEN` when configured, otherwise `GITHUB_TOKEN`; the token needs `actions:read` access to the source repository. Direct VSIX URLs are downloaded once and distributed unchanged, without GitHub API credentials. - For `java-gradle` and `java-gradle-java25`, CI validates the setup-selected Node 22 and Gradle versions, then rewrites the build terminal command with absolute Node, Gradle, JDK and diagnostic paths. This avoids tools being reselected by terminal shell initialization. The helper keeps its version, program-output and bytecode assertions, and writes toolchain identity, command output and uncaught failures under `test-results/toolchains//` for artifact upload. Local two-argument helper invocations retain their existing PATH/JDK-environment behavior; GitHub Actions requires explicit paths. The same binding sets the IDE's `java.import.gradle.home` and exports `ORG_GRADLE_PROJECT_javaRelease` before VS Code starts. Local runs must set that project property as documented in the fixture README. These plans require BSP import, actual Gradle `build`/`run` tasks, and the exact configuration marker in Gradle for Java output; terminal success alone is insufficient. CI also preserves Gradle/Java output-channel and extension-host logs under `test-results/toolchains//ide/`. diff --git a/.github/workflows/e2e-autotest.yml b/.github/workflows/e2e-autotest.yml index afc9d906..d87f2a12 100644 --- a/.github/workflows/e2e-autotest.yml +++ b/.github/workflows/e2e-autotest.yml @@ -102,62 +102,11 @@ jobs: echo "matrix=$matrix" >> "$GITHUB_OUTPUT" echo "Found plans: $matrix" - # Prepare supplied bits once, independently of the test matrix. - prepare-bits: - if: ${{ github.event_name == 'workflow_dispatch' && inputs.vsix_urls != '' }} - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Test VSIX preparation - shell: pwsh - run: ./.github/scripts/test-prepare-vsix.ps1 - - - name: Prepare platform VSIX files - shell: pwsh - env: - VSIX_SOURCES: ${{ inputs.vsix_urls }} - GITHUB_TOKEN: ${{ github.token }} - VSCODE_JAVA_ARTIFACT_TOKEN: ${{ secrets.VSCODE_JAVA_ARTIFACT_TOKEN }} - run: ./.github/scripts/prepare-vsix.ps1 -Sources $env:VSIX_SOURCES -OutputDirectory prepared-vsix - - - name: Upload Windows bits - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: override-vsix-Windows-X64 - path: prepared-vsix/win32-x64/*.vsix - if-no-files-found: error - compression-level: 0 - overwrite: true - retention-days: 1 - - - name: Upload Linux bits - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: override-vsix-Linux-X64 - path: prepared-vsix/linux-x64/*.vsix - if-no-files-found: error - compression-level: 0 - overwrite: true - retention-days: 1 - - - name: Upload macOS bits - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: override-vsix-macOS-ARM64 - path: prepared-vsix/darwin-arm64/*.vsix - if-no-files-found: error - compression-level: 0 - overwrite: true - retention-days: 1 - # ── Job 2: Run each test plan in parallel ─────────────── e2e-test: - needs: [discover, build-pack, prepare-bits] + needs: [discover, build-pack] # build-pack is skipped on schedule; PR/manual runs install this branch's pack VSIX first. - # Skipped preparation is allowed only without overrides; failure must not fall back to Marketplace. - if: ${{ always() && needs.discover.result == 'success' && (github.event_name == 'schedule' || needs.build-pack.result == 'success') && (needs.prepare-bits.result == 'success' || (needs.prepare-bits.result == 'skipped' && (github.event_name != 'workflow_dispatch' || inputs.vsix_urls == ''))) }} + if: ${{ always() && needs.discover.result == 'success' && (github.event_name == 'schedule' || needs.build-pack.result == 'success') }} runs-on: ${{ matrix.os }} timeout-minutes: 30 strategy: @@ -356,12 +305,142 @@ jobs: name: pack-vsix path: vsix - - name: Download prepared platform VSIX files + - name: Download VSIX files if: ${{ github.event_name == 'workflow_dispatch' && inputs.vsix_urls != '' }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: override-vsix-${{ runner.os }}-${{ runner.arch }} - path: vsix + shell: pwsh + env: + GITHUB_TOKEN: ${{ github.token }} + VSCODE_JAVA_ARTIFACT_TOKEN: ${{ secrets.VSCODE_JAVA_ARTIFACT_TOKEN }} + run: | + New-Item -ItemType Directory -Path vsix -Force | Out-Null + $urls = "${{ inputs.vsix_urls }}" -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" } + + # Map runner OS/arch to vscode-java platform identifiers + $platformMap = @{ "Windows" = "win32"; "Linux" = "linux"; "macOS" = "darwin" } + $archMap = @{ "X64" = "x64"; "ARM64" = "arm64" } + $platform = $platformMap["${{ runner.os }}"] + $arch = $archMap["${{ runner.arch }}"] + $platformId = "$platform-$arch" + Write-Host "Runner platform: $platformId (${{ runner.os }}/${{ runner.arch }})" + + $githubHeaders = @{ + Accept = "application/vnd.github+json" + Authorization = "Bearer $env:GITHUB_TOKEN" + "X-GitHub-Api-Version" = "2022-11-28" + "User-Agent" = "vscode-java-pack-autotest" + } + $artifactToken = if ($env:VSCODE_JAVA_ARTIFACT_TOKEN) { $env:VSCODE_JAVA_ARTIFACT_TOKEN } else { $env:GITHUB_TOKEN } + $githubArtifactHeaders = $githubHeaders.Clone() + if ($artifactToken) { + $githubArtifactHeaders.Authorization = "Bearer $artifactToken" + } + + function Select-VsixForPlatform { + param( + [Parameter(Mandatory = $true)] + [System.IO.FileInfo[]] $VsixFiles, + [Parameter(Mandatory = $true)] + [string] $PlatformId + ) + + $platformVsix = $VsixFiles | Where-Object { $_.Name -like "*-$PlatformId-*" } | Sort-Object Name | Select-Object -First 1 + if ($platformVsix) { + Write-Host " Found platform-specific VSIX: $($platformVsix.Name)" + return $platformVsix + } + + $universalVsix = $VsixFiles | Where-Object { $_.Name -notmatch '-(darwin|linux|win32)-' } | Sort-Object Name | Select-Object -First 1 + if ($universalVsix) { + Write-Host " No platform-specific VSIX found, using universal: $($universalVsix.Name)" + return $universalVsix + } + + throw "No matching VSIX found for platform $PlatformId. Available VSIX files: $($VsixFiles.Name -join ', ')" + } + + function Download-GitHubActionsRunVsix { + param( + [Parameter(Mandatory = $true)] + [string] $Owner, + [Parameter(Mandatory = $true)] + [string] $Repo, + [Parameter(Mandatory = $true)] + [string] $RunId + ) + + Write-Host "Resolving GitHub Actions run: $Owner/$Repo#$RunId for platform $platformId" + $artifactsUrl = "https://api.github.com/repos/$Owner/$Repo/actions/runs/$RunId/artifacts" + $artifacts = @((Invoke-RestMethod -Uri $artifactsUrl -Headers $githubArtifactHeaders -UseBasicParsing).artifacts | Where-Object { -not $_.expired }) + if (-not $artifacts) { + throw "No non-expired artifacts found for GitHub Actions run $Owner/$Repo#$RunId" + } + + $artifact = $artifacts | Where-Object { $_.name -eq "vscode-java" } | Select-Object -First 1 + if (-not $artifact -and $artifacts.Count -eq 1) { + $artifact = $artifacts | Select-Object -First 1 + } + if (-not $artifact) { + throw "No vscode-java artifact found for GitHub Actions run $Owner/$Repo#$RunId. Available artifacts: $($artifacts.name -join ', ')" + } + + $safeArtifactName = $artifact.name -replace '[^\w.-]', '_' + $downloadDir = Join-Path "downloaded-artifacts" "$Owner-$Repo-$RunId-$safeArtifactName" + $zipPath = "$downloadDir.zip" + New-Item -ItemType Directory -Path $downloadDir -Force | Out-Null + + Write-Host " Downloading artifact: $($artifact.name)" + try { + Invoke-WebRequest -Uri $artifact.archive_download_url -OutFile $zipPath -Headers $githubArtifactHeaders -UseBasicParsing + } catch { + throw "Failed to download artifact $($artifact.name) from $Owner/$Repo#$RunId. Cross-repo artifact downloads may require a VSCODE_JAVA_ARTIFACT_TOKEN secret with actions:read access to the source repository. $($_.Exception.Message)" + } + Expand-Archive -LiteralPath $zipPath -DestinationPath $downloadDir -Force + + $vsixFiles = @(Get-ChildItem $downloadDir -Recurse -Filter "*.vsix") + if ($vsixFiles.Count -eq 0) { + throw "Artifact $($artifact.name) from $Owner/$Repo#$RunId does not contain VSIX files" + } + + $selectedVsix = Select-VsixForPlatform -VsixFiles $vsixFiles -PlatformId $platformId + Copy-Item -LiteralPath $selectedVsix.FullName -Destination (Join-Path "vsix" $selectedVsix.Name) -Force + } + + $resolvedUrls = @() + foreach ($url in $urls) { + if ($url -match '^https://github\.com/([^/]+)/([^/]+)/releases/tag/(.+)$') { + $owner = $Matches[1]; $repo = $Matches[2]; $tag = $Matches[3] + Write-Host "Resolving GitHub release: $owner/$repo@$tag for platform $platformId" + $apiUrl = "https://api.github.com/repos/$owner/$repo/releases/tags/$tag" + $release = Invoke-RestMethod -Uri $apiUrl -Headers $githubHeaders -UseBasicParsing + $platformAsset = $release.assets | Where-Object { $_.name -like "*-$platformId-*" -and $_.name -like "*.vsix" } | Select-Object -First 1 + if ($platformAsset) { + Write-Host " Found platform-specific VSIX: $($platformAsset.name)" + $resolvedUrls += $platformAsset.browser_download_url + } else { + $universalAsset = $release.assets | Where-Object { $_.name -notmatch '-(darwin|linux|win32)-' -and $_.name -like "*.vsix" } | Select-Object -First 1 + if ($universalAsset) { + Write-Host " No platform-specific VSIX found, using universal: $($universalAsset.name)" + $resolvedUrls += $universalAsset.browser_download_url + } else { + Write-Host "::warning::No matching VSIX found in release $owner/$repo@$tag for platform $platformId" + } + } + } elseif ($url -match '^https://github\.com/([^/]+)/([^/]+)/actions/runs/(\d+)') { + Download-GitHubActionsRunVsix -Owner $Matches[1] -Repo $Matches[2] -RunId $Matches[3] + } elseif ($url -match '^\d+$') { + Download-GitHubActionsRunVsix -Owner "redhat-developer" -Repo "vscode-java" -RunId $url + } else { + $resolvedUrls += $url + } + } + + foreach ($url in $resolvedUrls) { + $fileName = [System.IO.Path]::GetFileName(($url -split '\?')[0]) + Write-Host "Downloading: $url → vsix/$fileName" + Invoke-WebRequest -Uri $url -OutFile "vsix/$fileName" -UseBasicParsing + } + Write-Host "Downloaded VSIX files:" + Get-ChildItem vsix -Filter "*.vsix" | ForEach-Object { Write-Host " $($_.Name) ($([math]::Round($_.Length/1MB, 1)) MB)" } - name: Use vscode-java-pack as primary extension for VSIX override runs if: ${{ github.event_name == 'workflow_dispatch' && inputs.vsix_urls != '' }} diff --git a/test-plans/java-go-to-super-implementation.yaml b/test-plans/java-go-to-super-implementation.yaml index 269d5c82..12e84376 100644 --- a/test-plans/java-go-to-super-implementation.yaml +++ b/test-plans/java-go-to-super-implementation.yaml @@ -4,8 +4,7 @@ # "Go to super implementation" link in the hover popup of an overriding # method was not clickable in 1.55.0. This plan hovers the overriding # method, clicks the hover link, and verifies the editor navigates to the -# super (base-class) implementation. The product fix is -# redhat-developer/vscode-java#4440; this plan still exercises the actual link. +# super (base-class) implementation. # # Verifies: LS ready -> hover overriding method -> "Go to super # implementation" hover link is present and clickable -> editor opens @@ -13,7 +12,6 @@ # # Prerequisites: # - JDK 11+ installed and available on PATH (the workflow installs JDK 21) -# - AutoTest 0.7.32+ for state-driven hover and active editor tab verification # # Usage: autotest run test-plans/java-go-to-super-implementation.yaml @@ -27,7 +25,8 @@ setup: extension: "redhat.java" extensions: - "vscjava.vscode-java-pack" - # Keep pre-release regression coverage; --vsix selects release bits explicitly. + # vscode-java 1.55.0 stable contains the #4438 regression covered by this + # plan; use the pre-release carrying the fix on PR runs as schedules do. preRelease: true vscodeVersion: "stable" workspace: "../test-fixtures/super-implementation" @@ -54,32 +53,41 @@ steps: verify: "Derived.java is open in the editor and shows the overriding greet() method" verifyEditor: contains: "public void greet" - verifyEditorTab: - title: "Derived.java" - active: true waitBefore: 5 timeout: 15 - # Wait for the actual clickable link, restoring a dismissed hover in the - # same operation. Loading text and a merely visible popup are not readiness. + # ── Hover the overriding method ─────────────────────────────── + # The first "greet" occurrence in the visible editor is the overriding + # declaration. Hovering it renders vscode-java's hover popup which + # includes the "Go to super implementation" link. The after-step + # screenshot captures the open popup; the LLM verify confirms the link + # is actually shown (the regression for #4438 is exactly this link + # being absent / not rendered as a clickable link). + - id: "hover-greet" + action: "hoverOnText greet" + verify: "A hover popup is open over the overriding greet() method, ready for the following link action" + waitBefore: 5 + timeout: 30 + # The popup can still show "Loading..." when the screenshot is captured on + # macOS. The next step deterministically proves that the link is available. + skipLlmVerify: true + + # ── Click the hover link ────────────────────────────────────── + # This is the regression assertion for #4438: the link must be a + # clickable element that triggers navigation to the super method. - id: "click-super-link" - action: 'hoverAndClickAction "greet" "Go to Super Implementation"' + action: "clickHoverAction Go to super implementation" verify: "The 'Go to super implementation' hover link was clicked and the editor navigated from Derived.java to the base class — Base.java is now opening with the cursor moving onto Base#greet()" - verifyEditorTab: - title: "Base.java" - active: true - timeout: 30 + timeout: 15 # ── Verify navigation landed on Base#greet() ────────────────── - id: "verify-base-open" - action: "wait 0 seconds" + action: "wait 2 seconds" verify: "Base.java is the active editor tab and the cursor sits on the base greet() method (Hello from Base body visible) — navigation to the super implementation succeeded via the hover link" verifyEditor: + fileName: "Base.java" contains: "Hello from Base" - verifyEditorTab: - title: "Base.java" - active: true timeout: 15 - # Navigation completed in the previous step, so this assertion + # Navigation completed in the previous step, so this settle-and-assert # step is visually unchanged by design. verifyEditor is authoritative. skipLlmVerify: true From 5c17c410cfce51db77e6d03dda45a8e6b6b562c0 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Sat, 10 Oct 2026 17:15:36 +0800 Subject: [PATCH 12/15] fix: preserve tooling metadata on project inventory errors --- .github/workflows/ci.yml | 2 + README.md | 2 + src/java-runtime/assets/ProjectJDKPanel.tsx | 8 +- src/java-runtime/assets/ToolingJDKPanel.tsx | 7 +- src/java-runtime/index.ts | 138 ++++++++++-------- src/java-runtime/types.ts | 4 +- tests/java-runtime.test.js | 153 +++++++++++++++++++- 7 files changed, 241 insertions(+), 73 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61f25264..b32cbda1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,4 +25,6 @@ jobs: - run: npm ci + - run: npm test + - run: npm run build diff --git a/README.md b/README.md index 26a21de9..a2075c99 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ To set the language-server launch JDK explicitly, configure `java.jdt.ls.java.ho Previously opened legacy runtime tabs can still be restored for compatibility; they are not opened by the current **Java: Configure Java Runtime** command. These tabs show the runtime reported by `redhat.java`, with **Refresh** and explicit JDK browsing/installation actions when no runtime is reported. Missing information is not treated as a startup failure, and upstream errors are shown alongside any available project configuration. The legacy tab's **Locate an Existing JDK** action saves an explicit tooling choice to `java.jdt.ls.java.home`, unlike the current project-settings picker. +Project inventory failures do not hide the reported language-server runtime or successfully queried projects. The legacy tab displays the query errors and indicates when project information could not be fully loaded; projects with unavailable type information are not treated as ordinary unmanaged projects. + ## Other Recommendations You can do more with VS Code. Here are some more recommendations that could help. diff --git a/src/java-runtime/assets/ProjectJDKPanel.tsx b/src/java-runtime/assets/ProjectJDKPanel.tsx index 26c45b4a..6949780a 100644 --- a/src/java-runtime/assets/ProjectJDKPanel.tsx +++ b/src/java-runtime/assets/ProjectJDKPanel.tsx @@ -88,7 +88,7 @@ export function ProjectJDKPanel({ jdkEntries, projectRuntimes, javaDotHome, tool

JDK {requiredJdkVersion}+ is required for the language server only; your projects can use older JDKs. Open language-server runtime settings.

{javaHomeError &&

{javaHomeError} Open Java Logs.

} {javaHomeWarning &&

{javaHomeWarning} Open Java Logs.

} - {projectJdkError &&

{projectJdkError}

} + {projectJdkError &&

{projectJdkError} Open Java Logs.

} {!javaDotHome && } {projectEntries.length > 0 &&

Manage Java runtime for your projects. If you don't have a valid Java runtime, you can download one.

} { @@ -105,7 +105,11 @@ export function ProjectJDKPanel({ jdkEntries, projectRuntimes, javaDotHome, tool :
-

No project detected yet. Please refresh later if Java extension is importing your projects.

+ {projectJdkError ? +

Project information could not be fully loaded. Please refresh to try again.

+ : +

No project detected yet. Please refresh later if Java extension is importing your projects.

+ }
} Refresh diff --git a/src/java-runtime/assets/ToolingJDKPanel.tsx b/src/java-runtime/assets/ToolingJDKPanel.tsx index 476eb792..99ddd74d 100644 --- a/src/java-runtime/assets/ToolingJDKPanel.tsx +++ b/src/java-runtime/assets/ToolingJDKPanel.tsx @@ -3,11 +3,13 @@ import "@vscode-elements/elements/dist/vscode-button/index.js"; -import { ToolingRuntimeInfo } from "../types"; +import { JavaRuntimeData, ToolingRuntimeInfo } from "../types"; import { JDKActions } from "./components/JDKActions"; import { onWillListRuntimes } from './vscode.api'; -export function ToolingJDKPanel({ javaHomeError, javaHomeWarning, javaDotHome, toolingJreVersion, requiredJdkVersion }: ToolingRuntimeInfo) { +type Props = ToolingRuntimeInfo & Pick; + +export function ToolingJDKPanel({ javaHomeError, javaHomeWarning, javaDotHome, toolingJreVersion, requiredJdkVersion, projectJdkError }: Props) { return (

Configure Runtime for Language Server

@@ -20,6 +22,7 @@ export function ToolingJDKPanel({ javaHomeError, javaHomeWarning, javaDotHome, t )} {javaHomeError && (

{javaHomeError}

)} {javaHomeWarning && (

{javaHomeWarning}

)} + {projectJdkError && (

{projectJdkError}

)}

Open Java Logs for details. Runtime selection and startup diagnostics are provided by redhat.java.

diff --git a/src/java-runtime/index.ts b/src/java-runtime/index.ts index 8776ff8c..83a08772 100644 --- a/src/java-runtime/index.ts +++ b/src/java-runtime/index.ts @@ -2,12 +2,10 @@ // Licensed under the MIT license. import { findRuntimes, getRuntime, IJavaRuntime } from "jdk-utils"; -import * as _ from "lodash"; import * as path from "path"; import * as vscode from "vscode"; import { getExtensionContext, getNonce } from "../utils"; import { getProjectNameFromUri, getProjectType } from "../utils/jdt"; -import { ProjectType } from "../utils/webview"; import { JavaRuntimeData, JavaRuntimeEntry, ProjectRuntimeEntry } from "./types"; import { sourceLevelDisplayName } from "./utils/misc"; import { getRequiredJdkVersion, getToolingRuntimeInfo } from "./utils/upstreamApi"; @@ -208,16 +206,15 @@ export class JavaRuntimeViewSerializer implements vscode.WebviewPanelSerializer } export async function findJavaRuntimeEntries(): Promise { - let projectJdkError: string | undefined; + const toolingRuntimeInfo = getToolingRuntimeInfo(); + const errors: string[] = []; try { if (!javaHomes) { const runtimes: IJavaRuntime[] = await findRuntimes({ checkJavac: true, withVersion: true }); javaHomes = runtimes.filter(r => r.hasJavac); } } catch (error) { - console.warn(error); - const message = error instanceof Error ? error.message : String(error); - projectJdkError = `Unable to list installed project JDKs: ${message}`; + recordInventoryError(errors, "Unable to list installed project JDKs", error); } const javaRuntimes: JavaRuntimeEntry[] = (javaHomes ?? []).map(elem => ({ name: elem.homedir, @@ -226,93 +223,110 @@ export async function findJavaRuntimeEntries(): Promise { type: "from jdk-utils" })).sort((a, b) => b.majorVersion - a.majorVersion); - let projectRuntimes = await getProjectRuntimesFromPM(); - if (_.isEmpty(projectRuntimes)) { - projectRuntimes = await getProjectRuntimesFromLS(); + const pmResult = await getProjectRuntimesFromPM(); + let projectRuntimes = pmResult.entries; + errors.push(...pmResult.errors); + if (projectRuntimes.length === 0) { + const lsResult = await getProjectRuntimesFromLS(); + projectRuntimes = lsResult.entries; + errors.push(...lsResult.errors); } return { javaRuntimes, projectRuntimes, - projectJdkError, - ...getToolingRuntimeInfo() + projectJdkError: errors.length > 0 ? errors.join("\n") : undefined, + ...toolingRuntimeInfo }; } -async function getProjectRuntimesFromPM(): Promise { - const ret: ProjectRuntimeEntry[] = []; +interface ProjectRuntimeResult { + entries: ProjectRuntimeEntry[]; + errors: string[]; +} + +function recordInventoryError(errors: string[], context: string, error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const diagnostic = `${context}: ${message}`; + console.warn(diagnostic, error); + errors.push(diagnostic); +} + +async function addProjectRuntime(result: ProjectRuntimeResult, rootPath: string, name?: string) { + try { + const runtimeSpec = await getRuntimeSpec(rootPath); + const projectType = getProjectType(vscode.Uri.parse(rootPath).fsPath, runtimeSpec.natureIds); + result.entries.push({ + name: name || getProjectNameFromUri(rootPath), + rootPath, + projectType, + ...runtimeSpec + }); + } catch (error) { + recordInventoryError(result.errors, `Unable to read runtime information for project "${name || rootPath}" (${rootPath})`, error); + } +} + +async function getProjectRuntimesFromPM(): Promise { + const result: ProjectRuntimeResult = { entries: [], errors: [] }; const projectManagerExt = vscode.extensions.getExtension("vscjava.vscode-java-dependency"); if (vscode.workspace.workspaceFolders && projectManagerExt && projectManagerExt.isActive) { - let projects: any[] = []; for (const wf of vscode.workspace.workspaceFolders) { + const folderUri = wf.uri.toString(); try { - projects = await vscode.commands.executeCommand("java.execute.workspaceCommand", "java.project.list", wf.uri.toString()) || []; + const projects = await vscode.commands.executeCommand<{ uri: string; displayName?: string; name: string }[]>( + "java.execute.workspaceCommand", "java.project.list", folderUri + ) || []; + for (const project of projects) { + await addProjectRuntime(result, project.uri, project.displayName || project.name); + } } catch (error) { - console.error(error); - } - - for (const project of projects) { - const runtimeSpec = await getRuntimeSpec(project.uri); - const projectType: ProjectType = getProjectType(vscode.Uri.parse(project.uri).fsPath, runtimeSpec.natureIds); - ret.push({ - name: project.displayName || project.name, - rootPath: project.uri, - projectType, - ...runtimeSpec - }); + recordInventoryError(result.errors, `Unable to list projects from Project Manager for workspace "${folderUri}"`, error); } } } - return ret; + return result; } -async function getProjectRuntimesFromLS(): Promise { - const ret: ProjectRuntimeEntry[] = []; +async function getProjectRuntimesFromLS(): Promise { + const result: ProjectRuntimeResult = { entries: [], errors: [] }; const javaExt = vscode.extensions.getExtension("redhat.java"); if (javaExt && javaExt.isActive) { - let projects: string[] = []; try { - projects = await vscode.commands.executeCommand("java.execute.workspaceCommand", "java.project.getAll") || []; + const projects = await vscode.commands.executeCommand( + "java.execute.workspaceCommand", "java.project.getAll" + ) || []; + for (const projectRoot of projects) { + await addProjectRuntime(result, projectRoot); + } } catch (error) { - // LS not ready - } - - for (const projectRoot of projects) { - const runtimeSpec = await getRuntimeSpec(projectRoot); - const projectType: ProjectType = await getProjectType(vscode.Uri.parse(projectRoot).fsPath, runtimeSpec.natureIds); - ret.push({ - name: getProjectNameFromUri(projectRoot), - rootPath: projectRoot, - projectType: projectType, - ...runtimeSpec - }); + recordInventoryError(result.errors, "Unable to list projects from the Java language server", error); } } - return ret; + return result; } async function getRuntimeSpec(projectRootUri: string) { - let natureIds; - let runtimePath; - let sourceLevel; const javaExt = vscode.extensions.getExtension("redhat.java"); - if (javaExt && javaExt.isActive) { - const NATURE_IDS = "org.eclipse.jdt.ls.core.natureIds"; - const SOURCE_LEVEL_KEY = "org.eclipse.jdt.core.compiler.source"; - const VM_INSTALL_PATH = "org.eclipse.jdt.ls.core.vm.location"; - try { - const settings: any = await javaExt.exports.getProjectSettings(projectRootUri, [NATURE_IDS, SOURCE_LEVEL_KEY, VM_INSTALL_PATH]); - natureIds = settings[NATURE_IDS]; - runtimePath = settings[VM_INSTALL_PATH]; - sourceLevel = settings[SOURCE_LEVEL_KEY]; - } catch (error) { - console.warn(error); - } + if (!javaExt?.isActive || typeof javaExt.exports?.getProjectSettings !== "function") { + throw new Error("Project settings are not available from redhat.java."); + } + const NATURE_IDS = "org.eclipse.jdt.ls.core.natureIds"; + const SOURCE_LEVEL_KEY = "org.eclipse.jdt.core.compiler.source"; + const VM_INSTALL_PATH = "org.eclipse.jdt.ls.core.vm.location"; + const settings: Record | undefined = await javaExt.exports.getProjectSettings( + projectRootUri, [NATURE_IDS, SOURCE_LEVEL_KEY, VM_INSTALL_PATH] + ); + const natureIds = settings?.[NATURE_IDS]; + if (!Array.isArray(natureIds) || !natureIds.every((natureId: unknown) => typeof natureId === "string")) { + throw new Error("Project type information is unavailable."); } + const runtimePath = settings?.[VM_INSTALL_PATH]; + const sourceLevel = settings?.[SOURCE_LEVEL_KEY]; return { natureIds, - runtimePath, - sourceLevel + runtimePath: typeof runtimePath === "string" ? runtimePath : undefined, + sourceLevel: typeof sourceLevel === "string" ? sourceLevel : undefined }; } diff --git a/src/java-runtime/types.ts b/src/java-runtime/types.ts index 281ea834..9f8d2ffb 100644 --- a/src/java-runtime/types.ts +++ b/src/java-runtime/types.ts @@ -21,8 +21,8 @@ export interface JdkData { export interface ProjectRuntimeEntry { name: string; rootPath: string; - runtimePath: string; - sourceLevel: string; + runtimePath?: string; + sourceLevel?: string; projectType: ProjectType; } diff --git a/tests/java-runtime.test.js b/tests/java-runtime.test.js index a0d2a2d4..efab2d63 100644 --- a/tests/java-runtime.test.js +++ b/tests/java-runtime.test.js @@ -18,7 +18,7 @@ function loadSource(relativePath, mocks, warnings, globals = {}, resolve = requi vm.runInNewContext(outputText, { module, exports: module.exports, process, performance, require: name => Object.prototype.hasOwnProperty.call(mocks, name) ? mocks[name] : resolve(name), - console: { warn: warning => warnings.push(warning) }, + console: { warn: warning => warnings.push(warning), error: error => warnings.push(error) }, ...globals, }, { filename }); return module.exports; @@ -65,6 +65,7 @@ function setupComponents() { function setup(version = "1.57.0") { const extension = { packageJSON: { version }, isActive: false, exports: {} }; + const projectManager = { isActive: false }; const preferences = {}; const discovered = []; const writes = []; @@ -87,8 +88,12 @@ function setup(version = "1.57.0") { onDidDispose: () => ({ dispose() {} }), }; const vscode = { - extensions: { getExtension: id => id === "redhat.java" ? extension : undefined }, - Uri: { file: fsPath => ({ fsPath }) }, ViewColumn: { One: 1 }, ConfigurationTarget: { Global: 1 }, + extensions: { + getExtension: id => id === "redhat.java" ? extension : + id === "vscjava.vscode-java-dependency" ? projectManager : undefined, + }, + Uri: { file: fsPath => ({ fsPath }), parse: uri => ({ fsPath: new URL(uri).pathname }) }, + ViewColumn: { One: 1 }, ConfigurationTarget: { Global: 1 }, window: { createWebviewPanel: () => panel, showOpenDialog: async () => undefined, showWarningMessage: async message => warnings.push(message), @@ -108,8 +113,12 @@ function setup(version = "1.57.0") { }; const mocks = { vscode, "jdk-utils": jdkUtils, "expand-home-dir": home => home }; const api = loadSource(path.join("src", "java-runtime", "utils", "upstreamApi.ts"), mocks, warnings); + const webviewUtils = loadSource(path.join("src", "utils", "webview.ts"), {}, warnings); + const jdt = loadSource(path.join("src", "utils", "jdt.ts"), { + vscode, "./webview": webviewUtils, + }, warnings); const loadView = () => loadSource(path.join("src", "java-runtime", "index.ts"), { - ...mocks, "../utils": { getNonce: () => "nonce" }, "../utils/jdt": {}, "../utils/webview": {}, + ...mocks, "../utils": { getNonce: () => "nonce" }, "../utils/jdt": jdt, "../utils/webview": webviewUtils, "./utils/misc": {}, "./utils/upstreamApi": api, }, warnings); const addJdk = (major, hasJavac = true) => { @@ -134,12 +143,25 @@ function setup(version = "1.57.0") { return view; }; return { - api, extension, preferences, discovered, writes, warnings, messages, calls, jdkUtils, vscode, + api, extension, projectManager, preferences, discovered, writes, warnings, messages, calls, jdkUtils, vscode, addJdk, report, loadView, openView, send: (command, payload = {}) => receiveMessage({ command, ...payload }), }; } +function setupInventory(s, source, projects) { + s.projectManager.isActive = source === "PM"; + s.vscode.workspace.workspaceFolders = [{ uri: { toString: () => "file:///workspace" } }]; + s.vscode.commands.executeCommand = async (_, command) => + command === "java.project.list" ? + projects.map(uri => ({ uri, name: path.basename(new URL(uri).pathname) })) : projects; + s.extension.exports.getProjectSettings = async () => ({ + "org.eclipse.jdt.ls.core.natureIds": ["org.eclipse.m2e.core.maven2Nature"], + "org.eclipse.jdt.core.compiler.source": "17", + "org.eclipse.jdt.ls.core.vm.location": "project-jdk", + }); +} + for (const [version, minimum] of [ ["1.38.0", 17], ["1.39.0", 21], ["1.56.0", 21], ["1.57.0", 25], ["1.57.2026093000", 25], ["1.58.0", 25], @@ -287,6 +309,101 @@ test("a failed project inventory is visible without claiming an unknown runtime assert.match(entries.projectJdkError, /Project discovery failed/); }); +for (const source of ["PM", "LS"]) { + test(`${source} preserves tooling and successful projects when settings or nature IDs are unavailable`, async () => { + const s = setup(); + const actual = s.addJdk(25); + s.report(actual); + setupInventory(s, source, [ + "file:///before", "file:///failed", "file:///missing-type", "file:///invalid-type", "file:///after", + ]); + const getSettings = s.extension.exports.getProjectSettings; + s.extension.exports.getProjectSettings = async uri => { + if (uri === "file:///failed") { + throw new Error("Settings request failed"); + } + const settings = await getSettings(); + if (uri === "file:///missing-type") { + delete settings["org.eclipse.jdt.ls.core.natureIds"]; + } else if (uri === "file:///invalid-type") { + settings["org.eclipse.jdt.ls.core.natureIds"] = [null]; + } else if (uri === "file:///after") { + settings["org.eclipse.jdt.ls.core.natureIds"] = []; + } + return settings; + }; + const entries = await s.loadView().findJavaRuntimeEntries(); + assert.equal(entries.javaDotHome, actual.homedir); + assert.equal(entries.toolingJreVersion, 25); + assert.equal(entries.javaHomeError, undefined); + assert.deepEqual(Array.from(entries.projectRuntimes, entry => entry.name), ["before", "after"]); + assert.deepEqual(Array.from(entries.projectRuntimes, entry => entry.projectType), ["Maven", "Others"]); + assert.ok(entries.projectRuntimes.every(entry => entry.sourceLevel === "17")); + assert.match(entries.projectJdkError, /failed.*Settings request failed/); + assert.match(entries.projectJdkError, /missing-type.*Project type information is unavailable/); + assert.match(entries.projectJdkError, /invalid-type.*Project type information is unavailable/); + assert.doesNotMatch(entries.projectJdkError, /includes/); + assert.equal(s.writes.length, 0); + assert.equal(s.calls.activation, 0); + }); +} + +test("a failed PM list falls back to LS while preserving its diagnostic", async () => { + const s = setup(); + const actual = s.addJdk(25); + s.report(actual); + setupInventory(s, "PM", ["file:///sample"]); + const executeCommand = s.vscode.commands.executeCommand; + s.vscode.commands.executeCommand = async (...args) => { + if (args[1] === "java.project.list") { + throw new Error("PM list failed"); + } + return executeCommand(...args); + }; + const entries = await s.loadView().findJavaRuntimeEntries(); + assert.equal(entries.javaDotHome, actual.homedir); + assert.deepEqual(Array.from(entries.projectRuntimes, entry => entry.name), ["sample"]); + assert.match(entries.projectJdkError, /workspace.*PM list failed/); +}); + +test("a failed PM workspace does not repeat previous projects or discard successful entries", async () => { + const s = setup(); + s.report(s.addJdk(25)); + setupInventory(s, "PM", ["file:///sample"]); + s.vscode.workspace.workspaceFolders.push({ uri: { toString: () => "file:///failed-workspace" } }); + const executeCommand = s.vscode.commands.executeCommand; + s.vscode.commands.executeCommand = async (...args) => { + assert.equal(args[1], "java.project.list"); + if (args[2] === "file:///failed-workspace") { + throw new Error("Workspace list failed"); + } + return executeCommand(...args); + }; + const entries = await s.loadView().findJavaRuntimeEntries(); + assert.deepEqual(Array.from(entries.projectRuntimes, entry => entry.name), ["sample"]); + assert.match(entries.projectJdkError, /failed-workspace.*Workspace list failed/); +}); + +test("failed disk and LS inventories publish tooling metadata and separate upstream diagnostics", async () => { + const s = setup(); + const actual = s.addJdk(25); + s.report(actual); + s.extension.exports.status = "Error"; + s.jdkUtils.findRuntimes = async () => { throw new Error("Disk scan failed"); }; + s.vscode.commands.executeCommand = async () => { throw new Error("LS list failed"); }; + await s.openView(); + await s.send("onWillListRuntimes"); + const entries = s.messages.at(-1).args; + assert.equal(entries.javaDotHome, actual.homedir); + assert.equal(entries.toolingJreVersion, 25); + assert.match(entries.javaHomeError, /redhat.java reports an error/); + assert.doesNotMatch(entries.javaHomeError, /Disk scan failed|LS list failed/); + assert.equal(entries.projectRuntimes.length, 0); + assert.match(entries.projectJdkError, /Disk scan failed/); + assert.match(entries.projectJdkError, /LS list failed/); + assert.ok(s.warnings.every(warning => !String(warning).includes("Unable to refresh Configure Java Runtime"))); +}); + test("project cache remains unchanged while refresh reads the latest upstream result", async () => { const s = setup(); s.discovered.push(s.addJdk(8)); @@ -381,6 +498,32 @@ test("component rendering preserves existing tooling error controls without proj assert.match(html, /Install a New JDK<\/b>/); }); +for (const status of [undefined, "Error"]) { + test(`component rendering shows inventory errors without projects when upstream status is ${status}`, () => { + const s = setup(); + const actual = s.addJdk(25); + s.report(actual); + s.extension.exports.status = status; + const components = setupComponents(); + components.load(path.join("src", "java-runtime", "assets", "index.ts")); + const html = components.show({ + ...s.api.getToolingRuntimeInfo(), javaRuntimes: [], projectRuntimes: [], + projectJdkError: "Unable to list projects: Settings request failed", + }); + assert.match(html, /Language server runtime reported by redhat.java/); + assert.ok(html.includes(actual.homedir)); + assert.match(html, /Settings request failed/); + assert.match(html, /href="command:java.open.logs"/); + assert.doesNotMatch(html, /No project detected yet/); + if (status === "Error") { + assert.match(html, /Configure Runtime for Language Server/); + assert.match(html, /redhat.java reports an error/); + } else { + assert.match(html, /Project information could not be fully loaded/); + } + }); +} + test("component rendering leaves unknown metadata informational with manual setup available", () => { const components = setupComponents(); components.load(path.join("src", "java-runtime", "assets", "index.ts")); From ed22fd165027aa624b239f3360b3fe16c8ea3445 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Mon, 12 Oct 2026 10:07:17 +0800 Subject: [PATCH 13/15] fix: align legacy javac-mode JDK requirements with upstream --- README.md | 4 +- src/java-runtime/utils/upstreamApi.ts | 13 ++- tests/java-runtime.test.js | 118 ++++++++++++++++++++------ 3 files changed, 107 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index a2075c99..4c409512 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,9 @@ By installing Extension Pack for Java, the following extensions are installed: ## Java Runtime Requirements -The JDK used to launch the Java language server is separate from the JDK used to compile and run your projects. The tooling minimum depends on the installed Language Support for Java extension: `redhat.java` 1.57.0 and newer requires JDK 25+, versions 1.39.0-1.56.x require JDK 21+, and earlier supported versions require JDK 17+. A compatible bundled runtime satisfies the tooling requirement; Java 27 project support does not require JDK 27 just to launch the language server. +The JDK used to launch the Java language server is separate from the JDK used to compile and run your projects. The tooling minimum depends on the installed Language Support for Java extension and compiler mode. `redhat.java` 1.57.0 and newer requires JDK 25+ regardless of compiler mode. With the normal compiler configuration, versions 1.39.0-1.56.x require JDK 21+, and earlier supported versions require JDK 17+. A compatible bundled runtime satisfies the tooling requirement; Java 27 project support does not require JDK 27 just to launch the language server. + +For older versions with `java.jdt.ls.javac.enabled=on`, the tooling minimum is JDK 23 for versions 1.36.0-1.40.x, JDK 24 for 1.41.0-1.45.x, and JDK 25 for 1.46.0-1.56.x. `off` and `auto` (where supported) keep the normal minimum; versions before 1.36.0 do not support this mode. Runtime guidance, the FAQ, and the legacy tooling-JDK picker share these requirements, which do not restrict project JDK choices. **Java: Configure Java Runtime** opens the project-settings JDK page. Its local-JDK picker changes the selected project's JDK, not the JDK used to launch the language server. Older project targets remain supported through `java.configuration.runtimes` and your Maven or Gradle build configuration. diff --git a/src/java-runtime/utils/upstreamApi.ts b/src/java-runtime/utils/upstreamApi.ts index 18da9285..e53dc336 100644 --- a/src/java-runtime/utils/upstreamApi.ts +++ b/src/java-runtime/utils/upstreamApi.ts @@ -21,7 +21,18 @@ export function getRequiredJdkVersion(): number { if (!javaExt || compareVersions(javaExt.packageJSON.version, "1.57.0") >= 0) { return 25; } - if (compareVersions(javaExt.packageJSON.version, "1.39.0") >= 0) { + const version = javaExt.packageJSON.version; + if (compareVersions(version, "1.36.0") >= 0 && + vscode.workspace.getConfiguration().get("java.jdt.ls.javac.enabled") === "on") { + if (compareVersions(version, "1.46.0") >= 0) { + return 25; + } + if (compareVersions(version, "1.41.0") >= 0) { + return 24; + } + return 23; + } + if (compareVersions(version, "1.39.0") >= 0) { return 21; } return 17; diff --git a/tests/java-runtime.test.js b/tests/java-runtime.test.js index efab2d63..995060d6 100644 --- a/tests/java-runtime.test.js +++ b/tests/java-runtime.test.js @@ -173,6 +173,34 @@ for (const [version, minimum] of [ }); } +test("explicit javac mode follows upstream JDK requirement boundaries without runtime probing", () => { + for (const [version, minimum] of [ + ["1.35.0", 17], ["1.36.0", 23], ["1.38.0", 23], ["1.39.0", 23], ["1.40.0", 23], + ["1.41.0", 24], ["1.45.0", 24], ["1.46.0", 25], ["1.56.0", 25], ["1.57.0", 25], + ]) { + const s = setup(version); + s.preferences["java.jdt.ls.javac.enabled"] = "on"; + assert.equal(s.api.getRequiredJdkVersion(), minimum, `redhat.java ${version}`); + const info = s.api.getToolingRuntimeInfo(); + assert.equal(info.requiredJdkVersion, minimum, `redhat.java ${version}`); + assert.equal(info.javaHomeError, undefined); + assert.deepEqual(s.calls, { discovery: 0, inspection: 0, activation: 0 }); + assert.equal(s.writes.length, 0); + } +}); + +test("off and auto javac modes preserve the normal tooling minimum", () => { + for (const mode of ["off", "auto"]) { + for (const [version, minimum] of [ + ["1.36.0", 17], ["1.39.0", 21], ["1.41.0", 21], ["1.46.0", 21], ["1.56.0", 21], ["1.57.0", 25], + ]) { + const s = setup(version); + s.preferences["java.jdt.ls.javac.enabled"] = mode; + assert.equal(s.api.getRequiredJdkVersion(), minimum, `redhat.java ${version}, javac=${mode}`); + } + } +}); + test("an inactive extension provides no runtime result and is not probed or activated", () => { const s = setup(); s.preferences["java.jdt.ls.java.home"] = "missing-jdk"; @@ -234,19 +262,22 @@ test("a reported upstream error is displayed without inventing its cause or open assert.equal(s.warnings.length, 0); }); -test("a known under-minimum setting is explained using only upstream-reported versions", () => { - const s = setup(); - const configured = s.addJdk(21); - const actual = s.addJdk(25); - s.preferences["java.jdt.ls.java.home"] = configured.homedir; - s.report(actual, configured); - const info = s.api.getToolingRuntimeInfo(); - assert.match(info.javaHomeWarning, /uses Java 21, below the required Java 25/); - assert.equal(info.javaDotHome, actual.homedir); - assert.equal(info.javaHomeError, undefined); - assert.equal(s.calls.inspection, 0); - assert.equal(s.writes.length, 0); -}); +for (const [version, mode] of [["1.57.0", undefined], ["1.56.0", "on"]]) { + test(`a known under-minimum setting for ${version}, javac=${mode} uses only upstream-reported versions`, () => { + const s = setup(version); + const configured = s.addJdk(21); + const actual = s.addJdk(25); + s.preferences["java.jdt.ls.java.home"] = configured.homedir; + s.preferences["java.jdt.ls.javac.enabled"] = mode; + s.report(actual, configured); + const info = s.api.getToolingRuntimeInfo(); + assert.match(info.javaHomeWarning, /uses Java 21, below the required Java 25/); + assert.equal(info.javaDotHome, actual.homedir); + assert.equal(info.javaHomeError, undefined); + assert.equal(s.calls.inspection, 0); + assert.equal(s.writes.length, 0); + }); +} test("an unexplained setting difference does not fabricate a version or fallback cause", () => { const s = setup(); @@ -447,6 +478,35 @@ for (const [version, jdk] of [[24, true], [25, false]]) { }); } +test("legacy javac tooling selection enforces its minimum without restricting older project JDKs", async () => { + for (const [version, minimum] of [["1.36.0", 23], ["1.41.0", 24], ["1.46.0", 25]]) { + const s = setup(version); + s.preferences["java.jdt.ls.javac.enabled"] = "on"; + const project = s.addJdk(17); + const actual = s.addJdk(minimum); + const invalid = s.addJdk(minimum - 1); + s.report(actual, project); + s.discovered.push(project); + const projectConfiguration = JSON.stringify([{ name: "JavaSE-17", path: project.homedir, default: true }]); + s.preferences["java.configuration.runtimes"] = JSON.parse(projectConfiguration); + s.vscode.window.showOpenDialog = async () => [{ fsPath: invalid.homedir }]; + await s.openView(); + await s.send("onWillListRuntimes"); + assert.equal(s.messages.at(-1).args.javaRuntimes[0].majorVersion, 17); + await s.send("onWillBrowseForJDK"); + assert.equal(s.writes.length, 0, `redhat.java ${version}`); + assert.match(s.warnings.at(-1), new RegExp(`Java ${minimum} or more recent is required`)); + + s.vscode.window.showOpenDialog = async () => [{ fsPath: actual.homedir }]; + await s.send("onWillBrowseForJDK"); + assert.deepEqual(s.writes, [{ setting: "java.jdt.ls.java.home", value: actual.homedir }]); + assert.equal(s.messages.at(-1).args.javaDotHome, actual.homedir); + assert.equal(s.messages.at(-1).args.javaRuntimes[0].majorVersion, 17); + assert.equal(JSON.stringify(s.preferences["java.configuration.runtimes"]), projectConfiguration); + assert.equal(s.calls.activation, 0); + } +}); + test("a project scan failure is displayed only as a project inventory problem", async () => { const s = setup(); s.jdkUtils.findRuntimes = async () => { throw new Error("Project discovery failed"); }; @@ -537,19 +597,25 @@ test("component rendering leaves unknown metadata informational with manual setu assert.doesNotMatch(html, /redhat.java reports an error/); }); -test("FAQ component distinguishes project configuration from the language-server setting", () => { - const { createElement } = require("react"); - const { renderToStaticMarkup } = require("react-dom/server"); - const { default: FaqPanel } = setupComponents().load(path.join("src", "beginner-tips", "assets", "tabs", "FaqPanel.tsx")); - const html = renderToStaticMarkup(createElement(FaqPanel, { requiredJdkVersion: 25 })); - assert.match(html, /JDK 25\+/); - assert.match(html, /href="command:java.runtime">Configure Java Runtime<\/a> page configures project JDKs/); - assert.match(html, /href="command:java.open.logs"/); - const settingLinks = [...html.matchAll(/href="command:java.webview.runCommand\?([^"]+)"/g)] - .map(match => JSON.parse(decodeURIComponent(match[1]))); - assert.deepEqual(settingLinks.find(link => link.command === "workbench.action.openSettings").args, ["java.jdt.ls.java.home"]); - assert.doesNotMatch(html, /guide shows the runtime reported/); -}); +for (const [version, mode, minimum] of [ + ["1.57.0", undefined, 25], ["1.41.0", "on", 24], ["1.56.0", "on", 25], +]) { + test(`FAQ component for ${version}, javac=${mode} shares the minimum and distinguishes project configuration`, () => { + const s = setup(version); + s.preferences["java.jdt.ls.javac.enabled"] = mode; + const { createElement } = require("react"); + const { renderToStaticMarkup } = require("react-dom/server"); + const { default: FaqPanel } = setupComponents().load(path.join("src", "beginner-tips", "assets", "tabs", "FaqPanel.tsx")); + const html = renderToStaticMarkup(createElement(FaqPanel, { requiredJdkVersion: s.api.getRequiredJdkVersion() })); + assert.match(html, new RegExp(`JDK ${minimum}\\+`)); + assert.match(html, /href="command:java.runtime">Configure Java Runtime<\/a> page configures project JDKs/); + assert.match(html, /href="command:java.open.logs"/); + const settingLinks = [...html.matchAll(/href="command:java.webview.runCommand\?([^"]+)"/g)] + .map(match => JSON.parse(decodeURIComponent(match[1]))); + assert.deepEqual(settingLinks.find(link => link.command === "workbench.action.openSettings").args, ["java.jdt.ls.java.home"]); + assert.doesNotMatch(html, /guide shows the runtime reported/); + }); +} test("startup preserves welcome and release-note scheduling without runtime preflight or setup", async () => { const s = setup(); From cb8eac3619fc008ee6bd1474623b115d1a9bf601 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Mon, 12 Oct 2026 12:37:13 +0800 Subject: [PATCH 14/15] ci: consolidate Gradle toolchain binding for JDK 21/25/27 --- .github/scripts/bind-gradle-toolchain.ps1 | 54 +++++----- .github/skills/uitest/SKILL.md | 4 +- .github/workflows/e2e-autotest.yml | 71 ++----------- test-fixtures/gradle-import/README.md | 12 ++- test-fixtures/java27/README.md | 11 +- tests/gradle-toolchain.test.js | 116 ++++++++++++++++++++++ 6 files changed, 169 insertions(+), 99 deletions(-) diff --git a/.github/scripts/bind-gradle-toolchain.ps1 b/.github/scripts/bind-gradle-toolchain.ps1 index 72e0258b..21014d3a 100644 --- a/.github/scripts/bind-gradle-toolchain.ps1 +++ b/.github/scripts/bind-gradle-toolchain.ps1 @@ -1,21 +1,32 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [ValidateSet("java-gradle", "java-gradle-java25")] + [ValidateSet("java-gradle", "java-gradle-java25", "java-gradle-java27")] [string] $PlanName ) $ErrorActionPreference = "Stop" $PSNativeCommandUseErrorActionPreference = $false -$release = if ($PlanName -eq "java-gradle-java25") { "25" } else { "21" } -$gradleVersion = if ($release -eq "25") { "9.8.1" } else { "8.5" } +$release = switch ($PlanName) { + "java-gradle" { "21" } + "java-gradle-java25" { "25" } + "java-gradle-java27" { "27" } +} +$gradleVersion = if ($release -eq "21") { "8.5" } else { "9.8.1" } $javaHome = [Environment]::GetEnvironmentVariable("JAVA${release}_HOME") if (-not $javaHome -or -not [System.IO.Path]::IsPathFullyQualified($javaHome) -or -not (Test-Path -LiteralPath $javaHome -PathType Container)) { throw "JAVA${release}_HOME must identify the installed project JDK" } -$logDirectory = Join-Path (Get-Location).Path "test-results/toolchains/$PlanName" +if (-not $env:AUTOTEST_STAGING_DIR -or + -not [System.IO.Path]::IsPathFullyQualified($env:AUTOTEST_STAGING_DIR)) { + throw "AUTOTEST_STAGING_DIR must identify the case's external diagnostic staging directory" +} +if ($env:GITHUB_ACTIONS -eq "true" -and -not $env:GITHUB_ENV) { + throw "GITHUB_ENV is required to share the bound toolchain with VS Code and Gradle" +} +$logDirectory = Join-Path $env:AUTOTEST_STAGING_DIR "terminal" New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null $node = (& node -p "process.execPath" | Out-String).Trim() if ($LASTEXITCODE -ne 0 -or -not [System.IO.Path]::IsPathFullyQualified($node)) { @@ -27,23 +38,26 @@ if (-not (Test-Path -LiteralPath (Join-Path $gradleHome "lib") -PathType Contain throw "The selected Gradle executable must belong to a Gradle installation: $gradle" } $env:JAVA_HOME = $javaHome -$env:ORG_GRADLE_PROJECT_javaRelease = $release $nodeVersion = (& $node --version 2>&1 | Out-String).Trim() $nodeExitCode = $LASTEXITCODE -Set-Content (Join-Path $logDirectory "ci-node-version.log") $nodeVersion +Set-Content (Join-Path $env:AUTOTEST_STAGING_DIR "node-version.log") $nodeVersion if ($nodeExitCode -ne 0 -or $nodeVersion -notmatch '^v22\.') { throw "Expected setup-selected Node 22 at ${node}, got: $nodeVersion" } $gradleInfo = (& $gradle --version 2>&1 | Out-String).Trim() $gradleExitCode = $LASTEXITCODE -Set-Content (Join-Path $logDirectory "ci-gradle-version.log") $gradleInfo +Set-Content (Join-Path $env:AUTOTEST_STAGING_DIR "gradle-version.log") "Command: $gradle --version`n$gradleInfo" if ($gradleExitCode -ne 0 -or $gradleInfo -notmatch "(?m)^Gradle $([regex]::Escape($gradleVersion))\r?$") { throw "Expected Gradle $gradleVersion at ${gradle}, got: $gradleInfo" } $quoteReplacement = if ($IsWindows) { "''" } else { "'" + '"' + "'" + '"' + "'" } -$arguments = @($node, "verify.mjs", $release, $gradleVersion, $gradle, $javaHome, $logDirectory) +$helperArguments = if ($release -eq "27") { "gradle project" } else { "$release $gradleVersion" } +$arguments = @($node, "verify.mjs") + $helperArguments.Split(" ") +if ($release -ne "27") { + $arguments += @($gradle, $javaHome, $logDirectory) +} $terminalCommand = ($arguments | ForEach-Object { "'" + $_.Replace("'", $quoteReplacement) + "'" }) -join " " if ($IsWindows) { $terminalCommand = "& $terminalCommand" @@ -51,7 +65,7 @@ if ($IsWindows) { $action = "executeVSCodeCommand workbench.action.terminal.sendSequence " + (@{ text = $terminalCommand + "`r" } | ConvertTo-Json -Compress) $originalAction = 'executeVSCodeCommand workbench.action.terminal.sendSequence {"text":"node verify.mjs ' + - "$release $gradleVersion" + '\u000d"}' + $helperArguments + '\u000d"}' $planFile = Join-Path "test-plans" "$PlanName.yaml" $content = Get-Content -LiteralPath $planFile -Raw $versionSetting = ' java.import.gradle.version: "' + $gradleVersion + '"' @@ -69,24 +83,16 @@ $boundValue = "'" + $action.Replace("'", "''") + "'" Set-Content -LiteralPath $planFile -Value $content.Replace($originalValue, $boundValue) if ($env:GITHUB_ACTIONS -eq "true") { - if (-not $env:GITHUB_ENV) { - throw "GITHUB_ENV is required to share the project release with VS Code and Gradle" + "GRADLE_CI_JAVA_HOME=$javaHome" >> $env:GITHUB_ENV + "GRADLE_CI_GRADLE=$gradle" >> $env:GITHUB_ENV + if ($release -eq "27") { + "JAVA27_CI_GRADLE=$gradle" >> $env:GITHUB_ENV + } else { + "ORG_GRADLE_PROJECT_javaRelease=$release" >> $env:GITHUB_ENV } - "ORG_GRADLE_PROJECT_javaRelease=$release" >> $env:GITHUB_ENV } -@{ - node = $node - nodeVersion = $nodeVersion - gradle = $gradle - gradleVersion = $gradleVersion - gradleHome = $gradleHome - javaHome = $javaHome - javaRelease = $release - projectJavaRelease = $env:ORG_GRADLE_PROJECT_javaRelease - terminalCommand = $terminalCommand -} | ConvertTo-Json | Set-Content (Join-Path $logDirectory "ci-toolchain.json") Write-Host "Bound Node $nodeVersion at $node" Write-Host "Bound Gradle $gradleVersion at $gradle with JDK $release at $javaHome" -Write-Host "Bound IDE Gradle home: $gradleHome; shared project release: $release" +Write-Host "Bound IDE Gradle home: $gradleHome" Write-Host "Toolchain diagnostics: $logDirectory" diff --git a/.github/skills/uitest/SKILL.md b/.github/skills/uitest/SKILL.md index d16ab66e..3a956cf0 100644 --- a/.github/skills/uitest/SKILL.md +++ b/.github/skills/uitest/SKILL.md @@ -77,6 +77,6 @@ The repository workflow `.github/workflows/e2e-autotest.yml` builds a branch VSI Use workflow dispatch when the user asks to validate in CI or across platforms. The `test_plan` input can target a single plan; leave it empty to run all plans. -For `java-gradle` and `java-gradle-java25`, CI validates the setup-selected Node 22 and Gradle versions, then rewrites the build terminal command with absolute Node, Gradle, JDK and diagnostic paths. This avoids tools being reselected by terminal shell initialization. The helper keeps its version, program-output and bytecode assertions, and writes toolchain identity, command output and uncaught failures under `test-results/toolchains//` for artifact upload. Local two-argument helper invocations retain their existing PATH/JDK-environment behavior; GitHub Actions requires explicit paths. +For `java-gradle`, `java-gradle-java25`, and `java-gradle-java27`, CI uses one Gradle setup and binding script. It validates the setup-selected Node 22 and Gradle versions, then binds their absolute paths for the build terminal command and sets the IDE's `java.import.gradle.home` to the same installation. Java 21/25 helpers receive explicit Gradle, JDK, and diagnostic paths; the Java 27 helper retains its `JAVA27_CI_GRADLE` contract. Existing version, program-output and bytecode assertions remain unchanged. Local helper invocations retain their existing behavior. -The same binding sets the IDE's `java.import.gradle.home` and exports `ORG_GRADLE_PROJECT_javaRelease` before VS Code starts. Local runs must set that project property as documented in the fixture README. These plans require BSP import, actual Gradle `build`/`run` tasks, and the exact configuration marker in Gradle for Java output; terminal success alone is insufficient. CI also preserves Gradle/Java output-channel and extension-host logs under `test-results/toolchains//ide/`. +For Java 21/25, the same binding exports `ORG_GRADLE_PROJECT_javaRelease` before VS Code starts. Local runs must set that project property as documented in the fixture README. These plans require BSP import, actual Gradle `build`/`run` tasks, and the exact configuration marker in Gradle for Java output; terminal success alone is insufficient. All three Gradle plans reuse the shared artifact collector: terminal diagnostics are staged outside AutoTest's output and archived under `diagnostics/ci/terminal/`, while Gradle/Java output-channel and extension-host logs are archived under `logs/ide/logs/`. diff --git a/.github/workflows/e2e-autotest.yml b/.github/workflows/e2e-autotest.yml index d87f2a12..2c6326be 100644 --- a/.github/workflows/e2e-autotest.yml +++ b/.github/workflows/e2e-autotest.yml @@ -179,12 +179,6 @@ jobs: "JAVA27_HOME=$target" >> $env:GITHUB_ENV Write-Host "Java 27 project toolchain: $target" - - name: Setup Gradle for Java 27 - if: contains(matrix.plan, 'java27') && contains(matrix.plan, 'gradle') - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - with: - gradle-version: '9.8.1' - - name: Setup Java 25 (for java25 test plans) if: contains(matrix.plan, 'java25') uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 @@ -232,40 +226,19 @@ jobs: (Get-Content $planFile -Raw).Replace('C:\\Program Files\\Java\\jdk-21', $jdkPath) | Set-Content $planFile } - - name: Setup matching Gradle CLI for import tests - if: matrix.plan == 'java-gradle' || matrix.plan == 'java-gradle-java25' + - name: Setup Gradle for import tests + if: matrix.plan == 'java-gradle' || matrix.plan == 'java-gradle-java25' || matrix.plan == 'java-gradle-java27' uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: - gradle-version: ${{ matrix.plan == 'java-gradle-java25' && '9.8.1' || '8.5' }} - - - name: Test Gradle toolchain binding - if: matrix.plan == 'java-gradle' || matrix.plan == 'java-gradle-java25' - run: node --test tests/gradle-toolchain.test.js + gradle-version: ${{ matrix.plan == 'java-gradle' && '8.5' || '9.8.1' }} - name: Bind CI toolchain for Gradle import tests - if: matrix.plan == 'java-gradle' || matrix.plan == 'java-gradle-java25' + if: matrix.plan == 'java-gradle' || matrix.plan == 'java-gradle-java25' || matrix.plan == 'java-gradle-java27' shell: pwsh env: GRADLE_TEST_PLAN: ${{ matrix.plan }} run: ./.github/scripts/bind-gradle-toolchain.ps1 -PlanName $env:GRADLE_TEST_PLAN - - name: Bind Gradle Java 27 toolchain - if: matrix.plan == 'java-gradle-java27' - shell: pwsh - run: | - $ciGradle = (Get-Command gradle -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source - $versionLog = Join-Path $env:AUTOTEST_STAGING_DIR "gradle-version.log" - $PSNativeCommandUseErrorActionPreference = $false - $gradleVersion = & $ciGradle --version 2>&1 | Out-String - $versionExit = $LASTEXITCODE - "Command: $ciGradle --version`n$gradleVersion" | Set-Content -LiteralPath $versionLog - if ($versionExit -ne 0) { throw "CI Gradle version probe exited with $versionExit; see diagnostics/ci/gradle-version.log in this case's results artifact." } - if ($gradleVersion -notmatch '(?m)^Gradle 9\.8\.1\s*$') { - throw "CI did not select the provisioned Gradle 9.8.1: $ciGradle; see $versionLog" - } - "JAVA27_CI_GRADLE=$ciGradle" >> $env:GITHUB_ENV - Write-Host "Bound Gradle 9.8.1: $ciGradle" - - name: Setup virtual display (Linux) if: runner.os == 'Linux' shell: bash @@ -459,8 +432,8 @@ jobs: run: | $identity = [ordered]@{ javaHome = $env:JAVA_HOME - projectJavaHome = $env:JAVA27_HOME - gradle = $env:JAVA27_CI_GRADLE + projectJavaHome = $(if ($env:GRADLE_CI_JAVA_HOME) { $env:GRADLE_CI_JAVA_HOME } else { $env:JAVA27_HOME }) + gradle = $env:GRADLE_CI_GRADLE runnerOs = $env:RUNNER_OS runnerArch = $env:RUNNER_ARCH path = $env:PATH @@ -543,38 +516,6 @@ jobs: & autotest collect --output $env:AUTOTEST_OUTPUT_DIR --artifacts-config (Join-Path "test-plans" "config" "artifacts.yaml") --plan "test-plans/${{ matrix.plan }}.yaml" exit $LASTEXITCODE - - name: Collect Gradle IDE diagnostics - if: ${{ always() && (matrix.plan == 'java-gradle' || matrix.plan == 'java-gradle-java25') }} - shell: pwsh - env: - GRADLE_TEST_PLAN: ${{ matrix.plan }} - run: | - $source = Join-Path (Get-Location).Path ".vscode-test/user-data/logs" - $destination = "test-results/toolchains/$env:GRADLE_TEST_PLAN/ide" - if (-not (Test-Path -LiteralPath $source -PathType Container)) { - Write-Warning "VS Code log directory is unavailable: $source" - } else { - $files = @(Get-ChildItem -LiteralPath $source -File -Recurse | Where-Object { - $_.Name -eq "exthost.log" -or $_.Name -like "*Gradle*.log" -or - $_.Name -like "*Language Support for Java*.log" - }) - if ($files.Count -eq 0) { - Write-Warning "No Gradle, Java output-channel or extension-host logs found in $source" - } - foreach ($file in $files) { - $target = Join-Path $destination ([System.IO.Path]::GetRelativePath($source, $file.FullName)) - New-Item -ItemType Directory -Path (Split-Path $target -Parent) -Force | Out-Null - Copy-Item -LiteralPath $file.FullName -Destination $target - } - Write-Host "Preserved $($files.Count) IDE diagnostic logs in $destination" - } - $toolchainDirectory = "test-results/toolchains/$env:GRADLE_TEST_PLAN" - if (Test-Path -LiteralPath $toolchainDirectory -PathType Container) { - $caseDiagnostics = Join-Path $env:AUTOTEST_OUTPUT_DIR "diagnostics/toolchains" - New-Item -ItemType Directory -Path $caseDiagnostics -Force | Out-Null - Copy-Item -LiteralPath $toolchainDirectory -Destination $caseDiagnostics -Recurse -Force - } - - name: Upload results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/test-fixtures/gradle-import/README.md b/test-fixtures/gradle-import/README.md index b67ad238..32825394 100644 --- a/test-fixtures/gradle-import/README.md +++ b/test-fixtures/gradle-import/README.md @@ -18,8 +18,9 @@ the expected configuration marker in Gradle for Java output, without task-model The temporary invalid completion probe is explicitly reloaded into the editor, then removed and saved before building. -CI installs the matching Gradle CLI and preserves `JAVA21_HOME`/`JAVA25_HOME`. -It binds `java.import.gradle.home` to the same installation and exports +CI shares one Gradle setup and binding script with `java-gradle-java27`, preserving +`JAVA21_HOME`/`JAVA25_HOME` for these cases. It binds Node and Gradle by absolute +path, sets `java.import.gradle.home` to the same installation, and exports `ORG_GRADLE_PROJECT_javaRelease` before launching VS Code. The Java import arguments alone do not configure the Gradle extension's separate task-model requests. For local runs, set the applicable variable, put that Gradle version on `PATH`, @@ -28,5 +29,8 @@ for your installation. The terminal verifier runs a clean build, checks the actual Gradle and Java versions, program output and class file version, and emits its success marker only after all checks pass. Generated IDE metadata and build logs are not fixture inputs. -CI preserves Gradle/Java output-channel and extension-host logs alongside the terminal -diagnostics so BSP transport failures cannot be inferred from JDT logs alone. +CI reuses the shared artifact collector: terminal diagnostics are staged outside +AutoTest's output and archived under `diagnostics/ci/terminal/`; Gradle/Java +output-channel and extension-host logs are archived under `logs/ide/logs/`. +The binding regressions in `tests/gradle-toolchain.test.js` require PowerShell 7, +which is available on the CI runners. diff --git a/test-fixtures/java27/README.md b/test-fixtures/java27/README.md index f3b8bd29..801e953f 100644 --- a/test-fixtures/java27/README.md +++ b/test-fixtures/java27/README.md @@ -45,8 +45,11 @@ For `java-gradle-java27`, CI also exports an absolute `JAVA27_CI_GRADLE` and uses that executable for the actual build instead of resolving Gradle from the terminal's PATH. Missing or relative CI paths fail explicitly, even when diagnostics are disabled; a failed CI build -does not retry with PATH Gradle. Node, the existing JDK 27 configuration and -terminal profiles are unchanged. +does not retry with PATH Gradle. The Gradle plan shares the Node/Gradle binding +script with the Java 21/25 import plans: Node 22 uses its provisioned absolute +path, and `java.import.gradle.home` points to the bound Gradle installation. +The existing JDK 27 configuration, helper arguments, and terminal profiles are +unchanged. Maven plans retain their existing Node invocation. The helper persists its actual terminal Node/PATH/JDK identity, selected Gradle command, command exit metadata, and full compiler/runtime output outside @@ -60,8 +63,8 @@ Java 27 bytecode and execution. The control never replaces the original failure or prints the plan's success marker. CI already uses that executable for the primary build and does not run a duplicate control after failure. -Gradle preparation only resolves the provisioned executable, verifies version -9.8.1 and binds it for the terminal helper. Version output is saved under +The shared Gradle preparation verifies the selected Node and Gradle versions +and binds their absolute paths for the terminal helper. Version output is saved under `diagnostics/ci/gradle-version.log`, not printed into the workflow log. For release comparisons, use `test_plan=java-gradle-java27`, `pre_release=false`, and an exact vscode-java release URL in `vsix_urls`; keep the failed run's release unchanged diff --git a/tests/gradle-toolchain.test.js b/tests/gradle-toolchain.test.js index 05851f68..f5050a60 100644 --- a/tests/gradle-toolchain.test.js +++ b/tests/gradle-toolchain.test.js @@ -3,7 +3,9 @@ const assert = require("node:assert/strict"); const fs = require("node:fs"); +const os = require("node:os"); const path = require("node:path"); +const { execFileSync } = require("node:child_process"); const test = require("node:test"); const vm = require("node:vm"); @@ -164,3 +166,117 @@ for (const plan of ["java-gradle", "java-gradle-java25"]) { assert.ok(ids.indexOf("verify-gradle-model-remains-healthy") > ids.indexOf("verify-build-and-run")); }); } + +function prepareBinding(t, release, actualGradleVersion = release === "21" ? "8.5" : "9.8.1") { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "gradle tools' ")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const plan = release === "21" ? "java-gradle" : `java-gradle-java${release}`; + const javaHome = path.join(root, `jdk-${release}`); + const gradleHome = path.join(root, "gradle"); + const staging = path.join(root, "staging"); + const envFile = path.join(root, "github-env"); + const invocation = path.join(root, "invocation.json"); + const nodeScript = path.join(root, "node.js"); + const node = path.join(root, process.platform === "win32" ? "node.cmd" : "node"); + const gradle = path.join(gradleHome, "bin", process.platform === "win32" ? "gradle.bat" : "gradle"); + for (const directory of [javaHome, path.dirname(gradle), path.join(gradleHome, "lib"), staging, path.join(root, "test-plans")]) { + fs.mkdirSync(directory, { recursive: true }); + } + fs.writeFileSync(envFile, ""); + fs.writeFileSync(nodeScript, ` + if (process.argv[2] === "--version") console.log("v22.23.2"); + else require("node:fs").writeFileSync(process.env.TOOL_BINDING_INVOCATION, JSON.stringify({ + args: process.argv.slice(2), gradle: process.env.JAVA27_CI_GRADLE + })); + `); + const shellQuote = value => "'" + value.replace(/'/g, "'\"'\"'") + "'"; + fs.writeFileSync(node, process.platform === "win32" + ? `@echo off\r\n"${process.execPath}" "${nodeScript}" %*\r\n` + : `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(nodeScript)} "$@"\n`, + { mode: 0o755 }); + fs.writeFileSync(gradle, process.platform === "win32" + ? `@echo off\r\necho Gradle ${actualGradleVersion}\r\n` + : `#!/bin/sh\nprintf 'Gradle ${actualGradleVersion}\\n'\n`, + { mode: 0o755 }); + const original = fs.readFileSync(path.join(__dirname, "..", "test-plans", `${plan}.yaml`), "utf8"); + const planFile = path.join(root, "test-plans", `${plan}.yaml`); + fs.writeFileSync(planFile, original); + const env = { + ...process.env, + GITHUB_ACTIONS: "true", + GITHUB_ENV: envFile, + AUTOTEST_STAGING_DIR: staging, + [`JAVA${release}_HOME`]: javaHome, + TOOL_BINDING_NODE: node, + TOOL_BINDING_GRADLE: gradle, + TOOL_BINDING_INVOCATION: invocation, + }; + const script = path.join(__dirname, "..", ".github", "scripts", "bind-gradle-toolchain.ps1"); + const harness = ` + $ErrorActionPreference = "Stop" + function global:node { $env:TOOL_BINDING_NODE; $global:LASTEXITCODE = 0 } + function global:Get-Command { + param($Name, $CommandType, $ErrorAction) + if ($Name -ne "gradle") { throw "Unexpected command lookup: $Name" } + [pscustomobject]@{ Source = $env:TOOL_BINDING_GRADLE } + } + & '${script.replace(/'/g, "''")}' -PlanName '${plan}' + `; + return { + root, planFile, original, javaHome, gradleHome, gradle, staging, envFile, invocation, env, + run: () => execFileSync("pwsh", ["-NoProfile", "-NonInteractive", "-OutputFormat", "Text", "-EncodedCommand", + Buffer.from(harness, "utf16le").toString("base64")], { cwd: root, env, encoding: "utf8", stdio: "pipe" }), + }; +} + +for (const release of ["21", "25", "27"]) { + test(`shared CI binding executes Java ${release} with selected tools and the existing diagnostic staging`, t => { + const s = prepareBinding(t, release); + s.run(); + const content = fs.readFileSync(s.planFile, "utf8"); + const prefix = "executeVSCodeCommand workbench.action.terminal.sendSequence "; + const actionLine = content.split(/\r?\n/).find(line => line.includes(`action: '${prefix}`)); + const action = actionLine.trim().slice("action: '".length, -1).replace(/''/g, "'"); + const command = JSON.parse(action.slice(prefix.length)).text.trimEnd(); + const bindings = Object.fromEntries(fs.readFileSync(s.envFile, "utf8").trim().split(/\r?\n/).map(line => { + const separator = line.indexOf("="); + return [line.slice(0, separator), line.slice(separator + 1)]; + })); + const env = { ...s.env, ...bindings }; + if (process.platform === "win32") { + execFileSync("pwsh", ["-NoProfile", "-NonInteractive", "-Command", command], { cwd: s.root, env }); + } else { + execFileSync("/bin/bash", ["--noprofile", "--norc", "-c", command], { cwd: s.root, env }); + } + const invoked = JSON.parse(fs.readFileSync(s.invocation, "utf8")); + const diagnostics = path.join(s.staging, "terminal"); + assert.equal(bindings.GRADLE_CI_JAVA_HOME, s.javaHome); + assert.equal(bindings.GRADLE_CI_GRADLE, s.gradle); + const homeLine = content.split(/\r?\n/).find(line => line.includes("java.import.gradle.home:")); + assert.equal(JSON.parse(homeLine.split("java.import.gradle.home:")[1]), s.gradleHome); + if (release === "27") { + assert.deepEqual(invoked.args, ["verify.mjs", "gradle", "project"]); + assert.equal(invoked.gradle, s.gradle); + } else { + assert.equal(bindings.ORG_GRADLE_PROJECT_javaRelease, release); + assert.deepEqual(invoked.args, ["verify.mjs", release, release === "21" ? "8.5" : "9.8.1", + s.gradle, s.javaHome, diagnostics]); + } + const unchangedLines = text => text.trimEnd().split(/\r?\n/).filter(line => + !line.includes("java.import.gradle.home:") && !line.includes(`action: '${prefix}`)); + assert.deepEqual(unchangedLines(content), unchangedLines(s.original)); + assert.match(fs.readFileSync(path.join(s.staging, "gradle-version.log"), "utf8"), + new RegExp(`Gradle ${release === "21" ? "8\\.5" : "9\\.8\\.1"}`)); + }); +} + +test("shared Java 27 binding rejects a wrong Gradle and preserves its version diagnostic before editing the plan", t => { + const s = prepareBinding(t, "27", "9.7.1"); + assert.throws(s.run, error => { + assert.match(error.stderr, /Expected Gradle 9\.8\.1/); + return true; + }); + assert.equal(fs.readFileSync(s.planFile, "utf8"), s.original); + assert.match(fs.readFileSync(path.join(s.staging, "gradle-version.log"), "utf8"), /Gradle 9\.7\.1/); + assert.equal(fs.readFileSync(s.envFile, "utf8"), ""); +}); From a1d890d8603755309cdc4835c1267a758ac27e87 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Mon, 12 Oct 2026 13:14:47 +0800 Subject: [PATCH 15/15] ci: remove extra tool binding and move runtime guidance to wiki --- .github/scripts/bind-gradle-toolchain.ps1 | 98 -------------- .github/skills/uitest/SKILL.md | 2 +- .github/workflows/e2e-autotest.yml | 23 +++- README.md | 16 +-- test-fixtures/gradle-import/README.md | 10 +- test-fixtures/gradle-import/verify.mjs | 23 ++-- test-fixtures/java27/README.md | 13 +- tests/gradle-toolchain.test.js | 151 ++++------------------ 8 files changed, 74 insertions(+), 262 deletions(-) delete mode 100644 .github/scripts/bind-gradle-toolchain.ps1 diff --git a/.github/scripts/bind-gradle-toolchain.ps1 b/.github/scripts/bind-gradle-toolchain.ps1 deleted file mode 100644 index 21014d3a..00000000 --- a/.github/scripts/bind-gradle-toolchain.ps1 +++ /dev/null @@ -1,98 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] - [ValidateSet("java-gradle", "java-gradle-java25", "java-gradle-java27")] - [string] $PlanName -) - -$ErrorActionPreference = "Stop" -$PSNativeCommandUseErrorActionPreference = $false -$release = switch ($PlanName) { - "java-gradle" { "21" } - "java-gradle-java25" { "25" } - "java-gradle-java27" { "27" } -} -$gradleVersion = if ($release -eq "21") { "8.5" } else { "9.8.1" } -$javaHome = [Environment]::GetEnvironmentVariable("JAVA${release}_HOME") -if (-not $javaHome -or -not [System.IO.Path]::IsPathFullyQualified($javaHome) -or - -not (Test-Path -LiteralPath $javaHome -PathType Container)) { - throw "JAVA${release}_HOME must identify the installed project JDK" -} - -if (-not $env:AUTOTEST_STAGING_DIR -or - -not [System.IO.Path]::IsPathFullyQualified($env:AUTOTEST_STAGING_DIR)) { - throw "AUTOTEST_STAGING_DIR must identify the case's external diagnostic staging directory" -} -if ($env:GITHUB_ACTIONS -eq "true" -and -not $env:GITHUB_ENV) { - throw "GITHUB_ENV is required to share the bound toolchain with VS Code and Gradle" -} -$logDirectory = Join-Path $env:AUTOTEST_STAGING_DIR "terminal" -New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null -$node = (& node -p "process.execPath" | Out-String).Trim() -if ($LASTEXITCODE -ne 0 -or -not [System.IO.Path]::IsPathFullyQualified($node)) { - throw "Could not resolve the setup-selected Node executable" -} -$gradle = (Get-Command gradle -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source -$gradleHome = Split-Path (Split-Path $gradle -Parent) -Parent -if (-not (Test-Path -LiteralPath (Join-Path $gradleHome "lib") -PathType Container)) { - throw "The selected Gradle executable must belong to a Gradle installation: $gradle" -} -$env:JAVA_HOME = $javaHome - -$nodeVersion = (& $node --version 2>&1 | Out-String).Trim() -$nodeExitCode = $LASTEXITCODE -Set-Content (Join-Path $env:AUTOTEST_STAGING_DIR "node-version.log") $nodeVersion -if ($nodeExitCode -ne 0 -or $nodeVersion -notmatch '^v22\.') { - throw "Expected setup-selected Node 22 at ${node}, got: $nodeVersion" -} -$gradleInfo = (& $gradle --version 2>&1 | Out-String).Trim() -$gradleExitCode = $LASTEXITCODE -Set-Content (Join-Path $env:AUTOTEST_STAGING_DIR "gradle-version.log") "Command: $gradle --version`n$gradleInfo" -if ($gradleExitCode -ne 0 -or $gradleInfo -notmatch "(?m)^Gradle $([regex]::Escape($gradleVersion))\r?$") { - throw "Expected Gradle $gradleVersion at ${gradle}, got: $gradleInfo" -} - -$quoteReplacement = if ($IsWindows) { "''" } else { "'" + '"' + "'" + '"' + "'" } -$helperArguments = if ($release -eq "27") { "gradle project" } else { "$release $gradleVersion" } -$arguments = @($node, "verify.mjs") + $helperArguments.Split(" ") -if ($release -ne "27") { - $arguments += @($gradle, $javaHome, $logDirectory) -} -$terminalCommand = ($arguments | ForEach-Object { "'" + $_.Replace("'", $quoteReplacement) + "'" }) -join " " -if ($IsWindows) { - $terminalCommand = "& $terminalCommand" -} -$action = "executeVSCodeCommand workbench.action.terminal.sendSequence " + - (@{ text = $terminalCommand + "`r" } | ConvertTo-Json -Compress) -$originalAction = 'executeVSCodeCommand workbench.action.terminal.sendSequence {"text":"node verify.mjs ' + - $helperArguments + '\u000d"}' -$planFile = Join-Path "test-plans" "$PlanName.yaml" -$content = Get-Content -LiteralPath $planFile -Raw -$versionSetting = ' java.import.gradle.version: "' + $gradleVersion + '"' -if (-not $content.Contains($versionSetting)) { - throw "Could not find the expected Gradle version setting in $planFile" -} -$content = $content.Replace($versionSetting, - $versionSetting + "`n java.import.gradle.home: " + ($gradleHome | ConvertTo-Json -Compress)) -$originalValue = "'" + $originalAction + "'" -if (-not $content.Contains($originalValue)) { - throw "Could not find the expected build helper command in $planFile" -} -# Escape YAML independently of the terminal shell and its JSON command arguments. -$boundValue = "'" + $action.Replace("'", "''") + "'" -Set-Content -LiteralPath $planFile -Value $content.Replace($originalValue, $boundValue) - -if ($env:GITHUB_ACTIONS -eq "true") { - "GRADLE_CI_JAVA_HOME=$javaHome" >> $env:GITHUB_ENV - "GRADLE_CI_GRADLE=$gradle" >> $env:GITHUB_ENV - if ($release -eq "27") { - "JAVA27_CI_GRADLE=$gradle" >> $env:GITHUB_ENV - } else { - "ORG_GRADLE_PROJECT_javaRelease=$release" >> $env:GITHUB_ENV - } -} - -Write-Host "Bound Node $nodeVersion at $node" -Write-Host "Bound Gradle $gradleVersion at $gradle with JDK $release at $javaHome" -Write-Host "Bound IDE Gradle home: $gradleHome" -Write-Host "Toolchain diagnostics: $logDirectory" diff --git a/.github/skills/uitest/SKILL.md b/.github/skills/uitest/SKILL.md index 3a956cf0..bd42b686 100644 --- a/.github/skills/uitest/SKILL.md +++ b/.github/skills/uitest/SKILL.md @@ -77,6 +77,6 @@ The repository workflow `.github/workflows/e2e-autotest.yml` builds a branch VSI Use workflow dispatch when the user asks to validate in CI or across platforms. The `test_plan` input can target a single plan; leave it empty to run all plans. -For `java-gradle`, `java-gradle-java25`, and `java-gradle-java27`, CI uses one Gradle setup and binding script. It validates the setup-selected Node 22 and Gradle versions, then binds their absolute paths for the build terminal command and sets the IDE's `java.import.gradle.home` to the same installation. Java 21/25 helpers receive explicit Gradle, JDK, and diagnostic paths; the Java 27 helper retains its `JAVA27_CI_GRADLE` contract. Existing version, program-output and bytecode assertions remain unchanged. Local helper invocations retain their existing behavior. +For `java-gradle`, `java-gradle-java25`, and `java-gradle-java27`, CI uses one Gradle setup and the existing environment-binding mechanism. Java 21/25 helpers read `GRADLE_CI_GRADLE`, their `JAVA21_HOME`/`JAVA25_HOME`, and `AUTOTEST_STAGING_DIR`; the Java 27 helper retains its `JAVA27_CI_GRADLE` contract. CI does not rewrite terminal commands or override the IDE's Gradle home. Actual version, program-output and bytecode assertions remain unchanged. Local helper invocations retain their existing behavior. For Java 21/25, the same binding exports `ORG_GRADLE_PROJECT_javaRelease` before VS Code starts. Local runs must set that project property as documented in the fixture README. These plans require BSP import, actual Gradle `build`/`run` tasks, and the exact configuration marker in Gradle for Java output; terminal success alone is insufficient. All three Gradle plans reuse the shared artifact collector: terminal diagnostics are staged outside AutoTest's output and archived under `diagnostics/ci/terminal/`, while Gradle/Java output-channel and extension-host logs are archived under `logs/ide/logs/`. diff --git a/.github/workflows/e2e-autotest.yml b/.github/workflows/e2e-autotest.yml index 2c6326be..74283b7c 100644 --- a/.github/workflows/e2e-autotest.yml +++ b/.github/workflows/e2e-autotest.yml @@ -232,12 +232,31 @@ jobs: with: gradle-version: ${{ matrix.plan == 'java-gradle' && '8.5' || '9.8.1' }} - - name: Bind CI toolchain for Gradle import tests + - name: Bind Gradle for import tests if: matrix.plan == 'java-gradle' || matrix.plan == 'java-gradle-java25' || matrix.plan == 'java-gradle-java27' shell: pwsh env: GRADLE_TEST_PLAN: ${{ matrix.plan }} - run: ./.github/scripts/bind-gradle-toolchain.ps1 -PlanName $env:GRADLE_TEST_PLAN + run: | + $ciGradle = (Get-Command gradle -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source + $release = if ($env:GRADLE_TEST_PLAN -eq "java-gradle") { "21" } elseif ($env:GRADLE_TEST_PLAN -eq "java-gradle-java25") { "25" } else { "27" } + if ($release -eq "27") { + $versionLog = Join-Path $env:AUTOTEST_STAGING_DIR "gradle-version.log" + $PSNativeCommandUseErrorActionPreference = $false + $gradleVersion = & $ciGradle --version 2>&1 | Out-String + $versionExit = $LASTEXITCODE + "Command: $ciGradle --version`n$gradleVersion" | Set-Content -LiteralPath $versionLog + if ($versionExit -ne 0) { throw "CI Gradle version probe exited with $versionExit; see diagnostics/ci/gradle-version.log in this case's results artifact." } + if ($gradleVersion -notmatch '(?m)^Gradle 9\.8\.1\s*$') { + throw "CI did not select the provisioned Gradle 9.8.1: $ciGradle; see $versionLog" + } + "JAVA27_CI_GRADLE=$ciGradle" >> $env:GITHUB_ENV + } else { + "ORG_GRADLE_PROJECT_javaRelease=$release" >> $env:GITHUB_ENV + } + "GRADLE_CI_GRADLE=$ciGradle" >> $env:GITHUB_ENV + "GRADLE_CI_JAVA_HOME=$([Environment]::GetEnvironmentVariable("JAVA${release}_HOME"))" >> $env:GITHUB_ENV + Write-Host "Bound CI Gradle: $ciGradle" - name: Setup virtual display (Linux) if: runner.os == 'Linux' diff --git a/README.md b/README.md index 4c409512..72e47920 100644 --- a/README.md +++ b/README.md @@ -25,20 +25,6 @@ By installing Extension Pack for Java, the following extensions are installed: - [📦 Project Manager for Java](https://marketplace.visualstudio.com/items?itemName=vscjava.vscode-java-dependency) - Manage Java projects, referenced libraries, resource files, packages, classes, and class members -## Java Runtime Requirements - -The JDK used to launch the Java language server is separate from the JDK used to compile and run your projects. The tooling minimum depends on the installed Language Support for Java extension and compiler mode. `redhat.java` 1.57.0 and newer requires JDK 25+ regardless of compiler mode. With the normal compiler configuration, versions 1.39.0-1.56.x require JDK 21+, and earlier supported versions require JDK 17+. A compatible bundled runtime satisfies the tooling requirement; Java 27 project support does not require JDK 27 just to launch the language server. - -For older versions with `java.jdt.ls.javac.enabled=on`, the tooling minimum is JDK 23 for versions 1.36.0-1.40.x, JDK 24 for 1.41.0-1.45.x, and JDK 25 for 1.46.0-1.56.x. `off` and `auto` (where supported) keep the normal minimum; versions before 1.36.0 do not support this mode. Runtime guidance, the FAQ, and the legacy tooling-JDK picker share these requirements, which do not restrict project JDK choices. - -**Java: Configure Java Runtime** opens the project-settings JDK page. Its local-JDK picker changes the selected project's JDK, not the JDK used to launch the language server. Older project targets remain supported through `java.configuration.runtimes` and your Maven or Gradle build configuration. - -To set the language-server launch JDK explicitly, configure `java.jdt.ls.java.home`. Open the Java logs (`java.open.logs`) for startup diagnostics. Runtime selection and startup diagnostics belong to `redhat.java`; Java Pack does not probe tooling candidates, activate the language extension to obtain runtime information, or automatically open runtime configuration based on its own checks. - -Previously opened legacy runtime tabs can still be restored for compatibility; they are not opened by the current **Java: Configure Java Runtime** command. These tabs show the runtime reported by `redhat.java`, with **Refresh** and explicit JDK browsing/installation actions when no runtime is reported. Missing information is not treated as a startup failure, and upstream errors are shown alongside any available project configuration. The legacy tab's **Locate an Existing JDK** action saves an explicit tooling choice to `java.jdt.ls.java.home`, unlike the current project-settings picker. - -Project inventory failures do not hide the reported language-server runtime or successfully queried projects. The legacy tab displays the query errors and indicates when project information could not be fully loaded; projects with unavailable type information are not treated as ordinary unmanaged projects. - ## Other Recommendations You can do more with VS Code. Here are some more recommendations that could help. @@ -77,6 +63,8 @@ At the same time, [📦 CheckStyle](https://marketplace.visualstudio.com/items?i ## Questions & Issues +See the [Java runtime requirements and configuration](https://github.com/microsoft/vscode-java-pack/wiki/Java-Runtime-Requirements) guide for JDK setup details. + Each extension mentioned above is a separate open-source project and has its own repository. To make things easier, simply [🙋 open an issue in this repository](https://github.com/Microsoft/vscode-java-pack/issues). The new issue will be triaged and redirected. ## Data/Telemetry diff --git a/test-fixtures/gradle-import/README.md b/test-fixtures/gradle-import/README.md index 32825394..037e86a6 100644 --- a/test-fixtures/gradle-import/README.md +++ b/test-fixtures/gradle-import/README.md @@ -18,9 +18,11 @@ the expected configuration marker in Gradle for Java output, without task-model The temporary invalid completion probe is explicitly reloaded into the editor, then removed and saved before building. -CI shares one Gradle setup and binding script with `java-gradle-java27`, preserving -`JAVA21_HOME`/`JAVA25_HOME` for these cases. It binds Node and Gradle by absolute -path, sets `java.import.gradle.home` to the same installation, and exports +CI shares the existing Gradle environment binding with `java-gradle-java27`, +preserving `JAVA21_HOME`/`JAVA25_HOME` for these cases. The two-argument helper +reads the provisioned absolute `GRADLE_CI_GRADLE` path from the environment. +CI does not rewrite its terminal command or override the IDE's Gradle home. +It also exports `ORG_GRADLE_PROJECT_javaRelease` before launching VS Code. The Java import arguments alone do not configure the Gradle extension's separate task-model requests. For local runs, set the applicable variable, put that Gradle version on `PATH`, @@ -32,5 +34,3 @@ Generated IDE metadata and build logs are not fixture inputs. CI reuses the shared artifact collector: terminal diagnostics are staged outside AutoTest's output and archived under `diagnostics/ci/terminal/`; Gradle/Java output-channel and extension-host logs are archived under `logs/ide/logs/`. -The binding regressions in `tests/gradle-toolchain.test.js` require PowerShell 7, -which is available on the CI runners. diff --git a/test-fixtures/gradle-import/verify.mjs b/test-fixtures/gradle-import/verify.mjs index 1282e1c6..0d7352df 100644 --- a/test-fixtures/gradle-import/verify.mjs +++ b/test-fixtures/gradle-import/verify.mjs @@ -5,24 +5,27 @@ import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; const workspace = path.dirname(fileURLToPath(import.meta.url)); -const [release, gradleVersion, boundGradle, boundJavaHome, boundLogDirectory] = process.argv.slice(2); +const [release, gradleVersion] = process.argv.slice(2); assert.ok(release === "21" || release === "25", "Expected Java 21 or Java 25"); assert.equal(gradleVersion, release === "21" ? "8.5" : "9.8.1", "Unexpected Gradle/JDK pair"); -if (process.env.GITHUB_ACTIONS === "true") { - assert.ok(boundGradle && boundJavaHome && boundLogDirectory, "CI requires explicit Gradle, JDK and diagnostic paths"); -} -for (const location of [boundGradle, boundJavaHome, boundLogDirectory]) { - if (location !== undefined) assert.ok(path.isAbsolute(location), `Expected an absolute toolchain path: ${location}`); -} -const javaHome = boundJavaHome ?? process.env[`JAVA${release}_HOME`]; +const isCI = process.env.GITHUB_ACTIONS === "true"; +const ciGradle = process.env.GRADLE_CI_GRADLE; +const stagingDirectory = process.env.AUTOTEST_STAGING_DIR; +const javaHome = process.env[`JAVA${release}_HOME`]; assert.ok(javaHome, `Set JAVA${release}_HOME to the project JDK`); -const gradleExecutable = boundGradle ?? "gradle"; +if (isCI) { + assert.ok(ciGradle && path.isAbsolute(ciGradle), "CI requires an absolute GRADLE_CI_GRADLE"); + assert.ok(stagingDirectory && path.isAbsolute(stagingDirectory), "CI requires an absolute AUTOTEST_STAGING_DIR"); + assert.ok(path.isAbsolute(javaHome), `Expected an absolute project JDK path: ${javaHome}`); +} +if (stagingDirectory) assert.ok(path.isAbsolute(stagingDirectory), "AUTOTEST_STAGING_DIR must be absolute"); +const gradleExecutable = isCI ? ciGradle : "gradle"; const env = { ...process.env, JAVA_HOME: javaHome }; const pathKey = Object.keys(env).find(key => key.toLowerCase() === "path") ?? "PATH"; env[pathKey] = `${path.join(javaHome, "bin")}${path.delimiter}${env[pathKey] ?? ""}`; const java = path.join(javaHome, "bin", process.platform === "win32" ? "java.exe" : "java"); -const logDirectory = boundLogDirectory ?? path.join(workspace, ".autotest"); +const logDirectory = stagingDirectory ? path.join(stagingDirectory, "terminal") : path.join(workspace, ".autotest"); mkdirSync(logDirectory, { recursive: true }); writeFileSync(path.join(logDirectory, "toolchain.json"), JSON.stringify({ node: process.execPath, diff --git a/test-fixtures/java27/README.md b/test-fixtures/java27/README.md index 801e953f..14b137a8 100644 --- a/test-fixtures/java27/README.md +++ b/test-fixtures/java27/README.md @@ -45,11 +45,10 @@ For `java-gradle-java27`, CI also exports an absolute `JAVA27_CI_GRADLE` and uses that executable for the actual build instead of resolving Gradle from the terminal's PATH. Missing or relative CI paths fail explicitly, even when diagnostics are disabled; a failed CI build -does not retry with PATH Gradle. The Gradle plan shares the Node/Gradle binding -script with the Java 21/25 import plans: Node 22 uses its provisioned absolute -path, and `java.import.gradle.home` points to the bound Gradle installation. -The existing JDK 27 configuration, helper arguments, and terminal profiles are -unchanged. Maven plans retain their existing Node invocation. +does not retry with PATH Gradle. Java 21/25 import plans reuse this environment +binding mechanism; no preparation script rewrites terminal commands or overrides +the IDE's Gradle home. Node invocation, JDK 27 configuration, helper arguments, +and terminal profiles remain unchanged. The helper persists its actual terminal Node/PATH/JDK identity, selected Gradle command, command exit metadata, and full compiler/runtime output outside @@ -63,8 +62,8 @@ Java 27 bytecode and execution. The control never replaces the original failure or prints the plan's success marker. CI already uses that executable for the primary build and does not run a duplicate control after failure. -The shared Gradle preparation verifies the selected Node and Gradle versions -and binds their absolute paths for the terminal helper. Version output is saved under +Java 27 Gradle preparation resolves the provisioned executable, verifies version +9.8.1 and binds it for the terminal helper. Version output is saved under `diagnostics/ci/gradle-version.log`, not printed into the workflow log. For release comparisons, use `test_plan=java-gradle-java27`, `pre_release=false`, and an exact vscode-java release URL in `vsix_urls`; keep the failed run's release unchanged diff --git a/tests/gradle-toolchain.test.js b/tests/gradle-toolchain.test.js index f5050a60..80a92337 100644 --- a/tests/gradle-toolchain.test.js +++ b/tests/gradle-toolchain.test.js @@ -3,9 +3,7 @@ const assert = require("node:assert/strict"); const fs = require("node:fs"); -const os = require("node:os"); const path = require("node:path"); -const { execFileSync } = require("node:child_process"); const test = require("node:test"); const vm = require("node:vm"); @@ -16,9 +14,10 @@ function execute(platform, release = "21", options = {}) { const paths = platform === "win32" ? path.win32 : path.posix; const root = platform === "win32" ? "C:\\CI Tools" : "/ci/tools with spaces"; const workspace = paths.join(root, "workspace"); - const javaHome = paths.join(root, `jdk-${release}`); + const javaHome = options.javaHome ?? paths.join(root, `jdk-${release}`); const gradle = paths.join(root, "gradle", "bin", platform === "win32" ? "gradle.bat" : "gradle"); - const logDirectory = paths.join(root, "results", "toolchain"); + const stagingDirectory = paths.join(root, "staging"); + const logDirectory = paths.join(stagingDirectory, "terminal"); const gradleVersion = release === "21" ? "8.5" : "9.8.1"; const calls = []; const files = new Map(); @@ -28,8 +27,6 @@ function execute(platform, release = "21", options = {}) { bytecode.writeUInt32BE(0xcafebabe); bytecode.writeUInt16BE(options.bytecodeMajor ?? Number(release) + 44, 6); const bound = options.bound !== false; - const argv = ["node", filename, release, gradleVersion]; - if (bound) argv.push(options.gradle ?? gradle, javaHome, logDirectory); const state = { paths, gradle, javaHome, logDirectory, calls, files, messages, run: () => { @@ -42,11 +39,15 @@ function execute(platform, release = "21", options = {}) { readFileSync: () => bytecode, console: { log: message => messages.push(message) }, process: { - argv, platform, execPath: paths.join(root, "node"), version: "v22.23.2", + argv: ["node", filename, release, gradleVersion], + platform, execPath: paths.join(root, "node"), version: "v22.23.2", env: { PATH: paths.join(root, "wrong-tools"), JAVA_HOME: paths.join(root, "wrong-jdk"), - [`JAVA${release}_HOME`]: bound ? paths.join(root, "wrong-jdk") : javaHome, + [`JAVA${release}_HOME`]: javaHome, + GRADLE_CI_GRADLE: bound ? options.gradle ?? gradle : undefined, + AUTOTEST_STAGING_DIR: "staging" in options ? options.staging : + options.ci === false ? undefined : stagingDirectory, GITHUB_ACTIONS: options.ci === false ? undefined : "true", ORG_GRADLE_PROJECT_javaRelease: "projectRelease" in options ? options.projectRelease : release, }, @@ -105,15 +106,29 @@ for (const platform of ["win32", "linux", "darwin"]) { } } -test("CI cannot silently fall back to PATH without explicit toolchain arguments", () => { +test("CI cannot silently fall back to PATH without the shared Gradle environment binding", () => { const s = execute("darwin", "21", { bound: false }); - assert.throws(s.run, /CI requires explicit Gradle, JDK and diagnostic paths/); + assert.throws(s.run, /CI requires an absolute GRADLE_CI_GRADLE/); assert.equal(s.calls.length, 0); }); test("relative bound executable paths are rejected before any command runs", () => { const s = execute("linux", "21", { gradle: "gradle" }); - assert.throws(s.run, /Expected an absolute toolchain path/); + assert.throws(s.run, /CI requires an absolute GRADLE_CI_GRADLE/); + assert.equal(s.calls.length, 0); +}); + +for (const staging of [undefined, "relative-staging"]) { + test(`CI rejects ${staging === undefined ? "missing" : "relative"} diagnostic staging`, () => { + const s = execute("linux", "21", { staging }); + assert.throws(s.run, /CI requires an absolute AUTOTEST_STAGING_DIR/); + assert.equal(s.calls.length, 0); + }); +} + +test("CI rejects a relative project JDK before running commands", () => { + const s = execute("linux", "21", { javaHome: "relative-jdk" }); + assert.throws(s.run, /Expected an absolute project JDK path/); assert.equal(s.calls.length, 0); }); @@ -166,117 +181,3 @@ for (const plan of ["java-gradle", "java-gradle-java25"]) { assert.ok(ids.indexOf("verify-gradle-model-remains-healthy") > ids.indexOf("verify-build-and-run")); }); } - -function prepareBinding(t, release, actualGradleVersion = release === "21" ? "8.5" : "9.8.1") { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "gradle tools' ")); - t.after(() => fs.rmSync(root, { recursive: true, force: true })); - const plan = release === "21" ? "java-gradle" : `java-gradle-java${release}`; - const javaHome = path.join(root, `jdk-${release}`); - const gradleHome = path.join(root, "gradle"); - const staging = path.join(root, "staging"); - const envFile = path.join(root, "github-env"); - const invocation = path.join(root, "invocation.json"); - const nodeScript = path.join(root, "node.js"); - const node = path.join(root, process.platform === "win32" ? "node.cmd" : "node"); - const gradle = path.join(gradleHome, "bin", process.platform === "win32" ? "gradle.bat" : "gradle"); - for (const directory of [javaHome, path.dirname(gradle), path.join(gradleHome, "lib"), staging, path.join(root, "test-plans")]) { - fs.mkdirSync(directory, { recursive: true }); - } - fs.writeFileSync(envFile, ""); - fs.writeFileSync(nodeScript, ` - if (process.argv[2] === "--version") console.log("v22.23.2"); - else require("node:fs").writeFileSync(process.env.TOOL_BINDING_INVOCATION, JSON.stringify({ - args: process.argv.slice(2), gradle: process.env.JAVA27_CI_GRADLE - })); - `); - const shellQuote = value => "'" + value.replace(/'/g, "'\"'\"'") + "'"; - fs.writeFileSync(node, process.platform === "win32" - ? `@echo off\r\n"${process.execPath}" "${nodeScript}" %*\r\n` - : `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(nodeScript)} "$@"\n`, - { mode: 0o755 }); - fs.writeFileSync(gradle, process.platform === "win32" - ? `@echo off\r\necho Gradle ${actualGradleVersion}\r\n` - : `#!/bin/sh\nprintf 'Gradle ${actualGradleVersion}\\n'\n`, - { mode: 0o755 }); - const original = fs.readFileSync(path.join(__dirname, "..", "test-plans", `${plan}.yaml`), "utf8"); - const planFile = path.join(root, "test-plans", `${plan}.yaml`); - fs.writeFileSync(planFile, original); - const env = { - ...process.env, - GITHUB_ACTIONS: "true", - GITHUB_ENV: envFile, - AUTOTEST_STAGING_DIR: staging, - [`JAVA${release}_HOME`]: javaHome, - TOOL_BINDING_NODE: node, - TOOL_BINDING_GRADLE: gradle, - TOOL_BINDING_INVOCATION: invocation, - }; - const script = path.join(__dirname, "..", ".github", "scripts", "bind-gradle-toolchain.ps1"); - const harness = ` - $ErrorActionPreference = "Stop" - function global:node { $env:TOOL_BINDING_NODE; $global:LASTEXITCODE = 0 } - function global:Get-Command { - param($Name, $CommandType, $ErrorAction) - if ($Name -ne "gradle") { throw "Unexpected command lookup: $Name" } - [pscustomobject]@{ Source = $env:TOOL_BINDING_GRADLE } - } - & '${script.replace(/'/g, "''")}' -PlanName '${plan}' - `; - return { - root, planFile, original, javaHome, gradleHome, gradle, staging, envFile, invocation, env, - run: () => execFileSync("pwsh", ["-NoProfile", "-NonInteractive", "-OutputFormat", "Text", "-EncodedCommand", - Buffer.from(harness, "utf16le").toString("base64")], { cwd: root, env, encoding: "utf8", stdio: "pipe" }), - }; -} - -for (const release of ["21", "25", "27"]) { - test(`shared CI binding executes Java ${release} with selected tools and the existing diagnostic staging`, t => { - const s = prepareBinding(t, release); - s.run(); - const content = fs.readFileSync(s.planFile, "utf8"); - const prefix = "executeVSCodeCommand workbench.action.terminal.sendSequence "; - const actionLine = content.split(/\r?\n/).find(line => line.includes(`action: '${prefix}`)); - const action = actionLine.trim().slice("action: '".length, -1).replace(/''/g, "'"); - const command = JSON.parse(action.slice(prefix.length)).text.trimEnd(); - const bindings = Object.fromEntries(fs.readFileSync(s.envFile, "utf8").trim().split(/\r?\n/).map(line => { - const separator = line.indexOf("="); - return [line.slice(0, separator), line.slice(separator + 1)]; - })); - const env = { ...s.env, ...bindings }; - if (process.platform === "win32") { - execFileSync("pwsh", ["-NoProfile", "-NonInteractive", "-Command", command], { cwd: s.root, env }); - } else { - execFileSync("/bin/bash", ["--noprofile", "--norc", "-c", command], { cwd: s.root, env }); - } - const invoked = JSON.parse(fs.readFileSync(s.invocation, "utf8")); - const diagnostics = path.join(s.staging, "terminal"); - assert.equal(bindings.GRADLE_CI_JAVA_HOME, s.javaHome); - assert.equal(bindings.GRADLE_CI_GRADLE, s.gradle); - const homeLine = content.split(/\r?\n/).find(line => line.includes("java.import.gradle.home:")); - assert.equal(JSON.parse(homeLine.split("java.import.gradle.home:")[1]), s.gradleHome); - if (release === "27") { - assert.deepEqual(invoked.args, ["verify.mjs", "gradle", "project"]); - assert.equal(invoked.gradle, s.gradle); - } else { - assert.equal(bindings.ORG_GRADLE_PROJECT_javaRelease, release); - assert.deepEqual(invoked.args, ["verify.mjs", release, release === "21" ? "8.5" : "9.8.1", - s.gradle, s.javaHome, diagnostics]); - } - const unchangedLines = text => text.trimEnd().split(/\r?\n/).filter(line => - !line.includes("java.import.gradle.home:") && !line.includes(`action: '${prefix}`)); - assert.deepEqual(unchangedLines(content), unchangedLines(s.original)); - assert.match(fs.readFileSync(path.join(s.staging, "gradle-version.log"), "utf8"), - new RegExp(`Gradle ${release === "21" ? "8\\.5" : "9\\.8\\.1"}`)); - }); -} - -test("shared Java 27 binding rejects a wrong Gradle and preserves its version diagnostic before editing the plan", t => { - const s = prepareBinding(t, "27", "9.7.1"); - assert.throws(s.run, error => { - assert.match(error.stderr, /Expected Gradle 9\.8\.1/); - return true; - }); - assert.equal(fs.readFileSync(s.planFile, "utf8"), s.original); - assert.match(fs.readFileSync(path.join(s.staging, "gradle-version.log"), "utf8"), /Gradle 9\.7\.1/); - assert.equal(fs.readFileSync(s.envFile, "utf8"), ""); -});