feat(run): share Java preparation and add incremental build policies (#2156)

Run and Debug now share one Java preparation path: save documents, build with the configured policy, resolve the module classpath and JDK, and stop on a failed build. Incremental Make is the default; Rebuild, NoBuild and Build-no-error-check are persisted on the run configuration, and the form offers only what a build tool supports. Delegated Gradle goes through the same path, Debug reports save failures, Maven snapshots honour Rebuild, and scoped diagnostics are retained so incremental Maven cannot erase compilation errors.

Co-authored-by: misswell <[email protected]>
Цей коміт міститься в:
misswellтаmisswell authored and GitHub committed 2026-11-09 03:18:13 +08:00
1 джерело 43bcd66b2b
коміт 9f9a2d8cf0
41 files changed
+868 -136

No files matched your search

+25 -1
Переглянути файл
@@ -40,6 +40,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
return { ...real, commands: { ...real.commands, ...cmds } };
});
const saveAll = vi.hoisted(() => vi.fn(async () => undefined));
const editor = vi.hoisted(() => ({
current: null as null | { ctx: { path: string; language: string }; view: { state: unknown } },
}));
@@ -55,7 +56,7 @@ vi.mock("@/workbench/contributions", async (importOriginal) => {
...real,
maybeEditorService: () => ({
openFile: async (path: string, o?: { focus?: boolean }) => void opened.push({ path, focus: o?.focus }),
saveAll: async () => undefined,
saveAll,
}),
};
});
@@ -438,3 +439,26 @@ it("stops the waiting Python container if attaching fails", async () => {
});
expect(ipc.runStop).toHaveBeenCalledWith(9);
});
it("reports Java Debug save failures and does not start the adapter", async () => {
const notify = vi.fn();
provideNotificationService({ notify });
useProject.setState({ project: { root: "/p" } as never });
saveAll.mockRejectedValueOnce(new Error("save failed"));
await executeAction("debug.startConfig", {
id: "main",
name: "Main",
kind: "application",
command: "java",
args: ["a.Main"],
cwd: null,
module: "",
env: {},
detected: false,
isTest: false,
debugAdapter: "java-debug",
});
expect(ipc.debugStart).not.toHaveBeenCalled();
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error", body: "save failed" }));
useProject.setState({ project: null });
});
+15 -2
Переглянути файл
@@ -78,7 +78,7 @@ describe("Java debugging (java-debug through jdtls)", () => {
expect(isJavaDebuggable({ ...app, kind: "maven" })).toBe(false);
const l = await javaLaunchConfig(app, "/p");
expect(invoked).toEqual([
["javaResolveLaunch", ["api", false, true, expect.any(Number)]],
["javaResolveLaunch", ["api", false, "automatic", expect.any(Number)]],
["javaDebugServer", ["api"]],
]);
expect(l).toMatchObject({ adapter: "java-debug", request: "launch", cwd: "/p/api", args: [], env: { A: "1" } });
@@ -178,7 +178,7 @@ it.each(["sibling", "/outside/modules"])("debugs from %s with the main class mod
for (const module of ["api", undefined]) {
invoked.length = 0;
const launch = await javaLaunchConfig({ ...app, module, cwd }, "/p");
expect(invoked).toContainEqual(["javaResolveLaunch", ["api", false, true, expect.any(Number)]]);
expect(invoked).toContainEqual(["javaResolveLaunch", ["api", false, "automatic", expect.any(Number)]]);
expect(invoked).toContainEqual(["javaDebugServer", ["api"]]);
expect(launch.cwd).toBe(cwd.startsWith("/") ? cwd : `/p/${cwd}`);
}
@@ -208,3 +208,16 @@ describe("naming the Java project jdtls imported", () => {
expect(await javaProjectName("/p")).toBe("p");
});
});
it("reports a save failure before starting Java Debug", async () => {
invoked.length = 0;
const saveAll = vi.fn().mockRejectedValue(new Error("save failed"));
await expect(
javaLaunchConfig(app, "/p", {
request: 1,
signal: new AbortController().signal,
saveAll,
}),
).rejects.toThrow("save failed");
expect(invoked).toEqual([]);
});
+8 -5
Переглянути файл
@@ -266,11 +266,14 @@ async function startJavaConfig(config: RunConfig, root: string, origin: RunConfi
};
signal.addEventListener("abort", cancel, { once: true });
try {
await maybeEditorService()
?.saveAll()
.catch(() => undefined);
signal.throwIfAborted();
return await javaLaunchConfig(config, root, { request, signal, report });
return await javaLaunchConfig(config, root, {
request,
signal,
report,
saveAll: async () => {
await maybeEditorService()?.saveAll();
},
});
} finally {
signal.removeEventListener("abort", cancel);
}
+4 -1
Переглянути файл
@@ -15,7 +15,7 @@ import {
prepareJavaLaunch,
type JavaPreparation,
} from "@/platform/javaLaunch";
import { notifications } from "@/workbench/contributions";
import { maybeEditorService, notifications } from "@/workbench/contributions";
export const JAVA_ADAPTER = "java-debug";
export const KOTLIN_ADAPTER = "kotlin-debug-adapter";
@@ -119,6 +119,9 @@ export async function javaLaunchConfig(
preparation ?? {
request: nextRequestId(),
signal: new AbortController().signal,
saveAll: async () => {
await maybeEditorService()?.saveAll();
},
},
);
if (info.errorCount > 0 && !preparation?.report)
+56 -5
Переглянути файл
@@ -3,10 +3,10 @@
* common ones, the options added through "Modify options" (⌥M) and the
* Before launch list.
*/
import { parseJavaArgs } from "@/platform/javaLaunch";
import { isJvmApplication, parseJavaArgs } from "@/platform/javaLaunch";
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react";
import clsx from "clsx";
import { isTauri, type BeforeLaunchTask, type RunConfig } from "@/platform/ipc";
import { isTauri, type BeforeLaunchTask, type BuildPolicy, type RunConfig } from "@/platform/ipc";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { relativePath, useProject } from "@/platform/project";
import { isRemoteWindow } from "@/platform/remote";
@@ -42,7 +42,9 @@ import {
parseEnv,
type ConfigErrors,
} from "./configs";
import { gradleContainsModule } from "./gradleRun";
import { EnvDialog } from "./EnvDialog";
import { useBuildModel } from "@/platform/buildModel";
import { withTarget } from "./remote";
import { COMPOUND, formParams, terminalApplies, withParams } from "./types";
import { TomcatSettings } from "./TomcatSettings";
@@ -76,12 +78,19 @@ export type OptionId =
| "showOnStdout"
| "showOnStderr"
| "beforeLaunch"
| "buildPolicy"
| "openToolWindow"
| "focusToolWindow"
| "isTest"
| "emulateTerminal";
const VALUE_OPTIONS: ReadonlySet<OptionId> = new Set(["stdinFile", "envFiles", "outputFile", "beforeLaunch"]);
const VALUE_OPTIONS: ReadonlySet<OptionId> = new Set([
"stdinFile",
"envFiles",
"outputFile",
"beforeLaunch",
"buildPolicy",
]);
const OPTION_LABELS: Record<OptionId, string> = {
allowParallel: "Allow multiple instances",
@@ -91,6 +100,7 @@ const OPTION_LABELS: Record<OptionId, string> = {
showOnStdout: "Show console when a message is printed to stdout",
showOnStderr: "Show console when a message is printed to stderr",
beforeLaunch: "Add before launch task",
buildPolicy: "Build",
openToolWindow: "Open run/debug tool window when started",
focusToolWindow: "Focus run/debug tool window when started",
isTest: "Show results in the test runner",
@@ -101,7 +111,11 @@ const OPTION_GROUPS: ReadonlyArray<{ label: string; options: OptionId[]; process
{ label: "Operating System", options: ["allowParallel", "stdinFile", "envFiles", "emulateTerminal"], process: true },
{ label: "Logs", options: ["outputFile", "showOnStdout", "showOnStderr"], process: true },
{ label: "Test", options: ["isTest"], process: true },
{ label: "Before Launch", options: ["beforeLaunch", "openToolWindow", "focusToolWindow"], process: false },
{
label: "Before Launch",
options: ["buildPolicy", "beforeLaunch", "openToolWindow", "focusToolWindow"],
process: false,
},
];
/** Whether the option currently has a (non-default) value. */
@@ -121,6 +135,8 @@ export function optionSet(config: RunConfig, id: OptionId, openByDefault: boolea
return !!config.showOnStderr;
case "beforeLaunch":
return !!config.beforeLaunch?.length;
case "buildPolicy":
return (config.buildPolicy ?? "automatic") !== "automatic";
case "openToolWindow":
return config.openToolWindow ?? openByDefault;
case "focusToolWindow":
@@ -145,6 +161,8 @@ export function optionPatch(id: OptionId, on: boolean): Partial<RunConfig> {
return { outputFile: null };
case "beforeLaunch":
return { beforeLaunch: [] };
case "buildPolicy":
return { buildPolicy: on ? "automatic" : null };
case "showOnStdout":
return { showOnStdout: on };
case "showOnStderr":
@@ -160,6 +178,14 @@ export function optionPatch(id: OptionId, on: boolean): Partial<RunConfig> {
}
}
const BUILD_POLICY_OPTIONS: SelectOption[] = [
{ value: "automatic", label: "Automatic (incremental)" },
{ value: "make", label: "Build (Make)" },
{ value: "rebuild", label: "Rebuild" },
{ value: "noBuild", label: "No build" },
{ value: "makeAndContinueOnErrors", label: "Build, no error check" },
];
export interface ConfigFormProps {
config: RunConfig;
/** Every configuration of the dialog (compound members, before launch targets, name checks). */
@@ -222,6 +248,17 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi
const tx = useTx();
const txTemplate = useTxTemplate();
const root = useProject((s) => s.project?.root ?? null);
const gradle = useBuildModel((s) => s.model?.gradle);
const runUsing = useProject((s) => s.settings?.languages?.java?.gradle?.runUsing);
const delegated =
isJvmApplication(config) &&
runUsing !== "ide" &&
gradle?.some((g) => config.module == null || gradleContainsModule(g, root ?? "", config.module));
const buildPolicyOptions = BUILD_POLICY_OPTIONS.filter((option) => {
if (config.kind === "gradle" || delegated)
return option.value !== "noBuild" && option.value !== "makeAndContinueOnErrors";
return config.kind !== "maven" || option.value !== "noBuild";
});
const [shown, setShown] = useState<ReadonlySet<OptionId>>(() => new Set());
const [argsText, setArgsText] = useState<string | null>(null);
const [envText, setEnvText] = useState<string | null>(null);
@@ -239,6 +276,7 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi
}
const compound = config.kind === COMPOUND;
const buildPolicyApplies = (c: RunConfig) => isJvmApplication(c) || c.kind === "maven" || c.kind === "gradle";
// Compound and debug-only types (Remote JVM Debug) start no process of their own.
const noProcess = compound || !!type?.debugOnly;
const params = compound ? {} : formParams(config, type);
@@ -275,7 +313,9 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi
...g.options
.filter(
(id) =>
(!compound || id === "beforeLaunch") && (id !== "emulateTerminal" || terminal || !!config.emulateTerminal),
(!compound || id === "beforeLaunch") &&
(id !== "emulateTerminal" || terminal || !!config.emulateTerminal) &&
(id !== "buildPolicy" || buildPolicyApplies(config)),
)
.map((id): MenuItem => ({
label: OPTION_LABELS[id],
@@ -629,6 +669,17 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi
</>
)}
{isShown("buildPolicy") && buildPolicyApplies(config) && (
<Field label="Build" hint="Before launch build for this Java configuration">
<Select
value={config.buildPolicy ?? "automatic"}
options={buildPolicyOptions}
onChange={(v) => update({ buildPolicy: v === "automatic" ? null : (v as BuildPolicy) })}
aria-label="Build"
/>
</Field>
)}
{isShown("beforeLaunch") && (
<Field label="Before launch" error={errors.beforeLaunch} top>
<div className={styles.taskList} role="list" aria-label={tx("Before launch tasks")}>
+2 -2
Переглянути файл
@@ -131,7 +131,7 @@ const boot = {
describe("Spring Boot application updates", () => {
it("processes resources and publishes them to the resolved runtime output", async () => {
await updateTomcat(boot, "resources");
expect(mocks.resolve).toHaveBeenCalledWith("app", false, false, 1);
expect(mocks.resolve).toHaveBeenCalledWith("app", false, "noBuild", 1);
expect(mocks.runBuild).toHaveBeenCalledWith({ id: "build" });
expect(mocks.publish).toHaveBeenCalledWith(boot.config, ["/p/app/bin/main"], "a.Main");
expect(mocks.update).not.toHaveBeenCalled();
@@ -139,7 +139,7 @@ describe("Spring Boot application updates", () => {
});
it("compiles and invokes the existing scoped HotSwap action in debug", async () => {
await updateTomcat(boot, "classes");
expect(mocks.resolve).toHaveBeenCalledWith("app", false, true, 1);
expect(mocks.resolve).toHaveBeenCalledWith("app", false, "make", 1);
expect(mocks.action).toHaveBeenCalledWith("debug.reloadChangedClasses", { configId: "boot" });
});
it("explains compiled classes require restart without a debugger", async () => {
+6 -1
Переглянути файл
@@ -43,7 +43,12 @@ async function updateSpringBoot(config: RunConfig, mode: UpdateMode, debugging:
const parsed = parseJavaArgs(config.args);
if (!parsed.mainClass) throw new Error(tx("The Spring Boot configuration has no main class"));
const info = await call(
commands.javaResolveLaunch(module, parsed.classpath === "test", mode === "classes", nextRequestId()),
commands.javaResolveLaunch(
module,
parsed.classpath === "test",
mode === "classes" ? "make" : "noBuild",
nextRequestId(),
),
);
if (info.build === "failed" || info.build === "withErrors" || info.build === "cancelled")
throw new Error(tx("Application update build failed"));
+4 -2
Переглянути файл
@@ -2,9 +2,9 @@
* Before launch tasks (IDEA's "Before launch" list): run in order before a
* configuration starts; the first failure aborts the launch.
*/
import { useProject } from "@/platform/project";
import { buildCMake, isCMakeBuild } from "./cmake";
import { dotnetBuildConfig } from "./dotnetTypes";
import { useProject } from "@/platform/project";
import { useBuildPresentation } from "@/platform/projectPresentation";
import { buildTask } from "./buildTask";
import { moduleTaskError } from "./configs";
@@ -88,7 +88,9 @@ async function runTask(
return (await runToEnd(dotnetBuildConfig(config), chain, show)) ? null : "Build failed";
case "build": {
if (target) return buildCMake(target, show === true);
return buildTask(BUILD_ACTION, { activate: false });
const error = await buildTask(BUILD_ACTION, { activate: false });
if (error) return error;
return null;
}
case "buildModule": {
const error = moduleTaskError(config, target);
+44
Переглянути файл
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import type { RunConfig } from "@/platform/ipc";
import { applyBuildPolicyToCommand } from "./buildCommand";
const cfg = (extra: Partial<RunConfig>): RunConfig => ({
id: "m",
name: "m",
kind: "maven",
command: "mvn",
args: ["-B", "spring-boot:run"],
cwd: null,
env: {},
detected: false,
isTest: false,
debugAdapter: null,
...extra,
});
describe("build policy for build-tool commands", () => {
it("puts clean first for Maven and Gradle Rebuild", () => {
expect(applyBuildPolicyToCommand({ ...cfg({ buildPolicy: "rebuild" }), kind: "maven" }).args).toEqual([
"clean",
"-B",
"spring-boot:run",
]);
expect(
applyBuildPolicyToCommand({ ...cfg({ kind: "gradle", args: ["bootRun"] }), buildPolicy: "rebuild" }).args,
).toEqual(["clean", "bootRun"]);
});
it("lets Maven continue on compile errors only when asked", () => {
expect(applyBuildPolicyToCommand({ ...cfg({ buildPolicy: "makeAndContinueOnErrors" }) }).args).toContain(
"-Dmaven.compiler.failOnError=false",
);
expect(applyBuildPolicyToCommand({ ...cfg({ buildPolicy: "make" }) }).args).not.toContain(
"-Dmaven.compiler.failOnError=false",
);
});
it("leaves Automatic and NoBuild commands alone", () => {
for (const buildPolicy of ["automatic", "noBuild"] as const)
expect(applyBuildPolicyToCommand(cfg({ buildPolicy }))).toEqual(cfg({ buildPolicy }));
});
});
+22
Переглянути файл
@@ -0,0 +1,22 @@
/**
* Build policies for configurations whose command is itself a build tool (Maven,
* Gradle). Application/Spring Boot configurations build through `prepareJavaLaunch`
* instead; this only rewrites the command of an already chosen build-tool launch.
*/
import type { RunConfig } from "@/platform/ipc";
const MAVEN = new Set(["maven"]);
const GRADLE = new Set(["gradle"]);
export function applyBuildPolicyToCommand(config: RunConfig): RunConfig {
const rebuild = config.buildPolicy === "rebuild";
if (rebuild && !config.args.includes("clean") && (MAVEN.has(config.kind) || GRADLE.has(config.kind))) {
// Maven and Gradle both accept the clean task before their other goals.
return { ...config, args: ["clean", ...config.args] };
}
if (config.buildPolicy === "makeAndContinueOnErrors" && MAVEN.has(config.kind)) {
const flag = "-Dmaven.compiler.failOnError=false";
return config.args.includes(flag) ? config : { ...config, args: [...config.args, flag] };
}
return config;
}
+12
Переглянути файл
@@ -265,3 +265,15 @@ describe("delegation across independent builds", () => {
expect(run!.args.at(-1)).toBe(":app:openideaRun");
});
});
it("uses the same Rebuild preparation for delegated Run and Debug", () => {
const app = {
...applicationConfig({ className: "a.Main" }, "app/src/main/java/a/Main.java", []),
buildPolicy: "rebuild" as const,
};
const run = delegatedGradleConfig(app, "app", ctx)!;
expect(run.args[0]).toBe("clean");
const debug = jdwpLaunch(run)!;
expect(debug.args.filter((a) => a === "clean")).toHaveLength(1);
expect(debug.args).toContain(DELEGATED_DEBUG);
});
+13 -6
Переглянути файл
@@ -10,8 +10,10 @@ import TESTS_SCRIPT from "./gradleTests.gradle?raw";
import BOOT_SCRIPT from "./gradleBoot.gradle?raw";
import { useBuildModel } from "@/platform/buildModel";
import { call, commands, isTauri, type GradleModel, type RunConfig } from "@/platform/ipc";
import { findJavaModule, isJvmApplication, parseJavaArgs } from "@/platform/javaLaunch";
import { isJvmApplication, parseJavaArgs, prepareJavaModule } from "@/platform/javaLaunch";
import { useProject } from "@/platform/project";
import { maybeEditorService } from "@/workbench/contributions";
import { applyBuildPolicyToCommand } from "./buildCommand";
/** System property that marks a delegated run (read by `jdwpLaunch` to add the debug flag). */
export const DELEGATED_MAIN = "-Dopenidea.run.main=";
@@ -21,6 +23,10 @@ const TASK = "openideaRun";
const isAbsolute = (p: string) => p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p);
const absolute = (root: string, p: string) => (isAbsolute(p) ? p : p ? `${root}/${p}` : root);
export function gradleContainsModule(gradle: GradleModel, root: string, module: string): boolean {
return gradle.projects.some((p) => absolute(root, p.dir) === absolute(root, module));
}
export interface DelegationContext {
/** Project root. */
root: string;
@@ -74,7 +80,7 @@ export function delegatedGradleConfig(config: RunConfig, module: string, ctx: De
// A Gradle run from here on: not an Application again (it would be delegated twice), and
// without the Application's params (Gradle's own VM-options init script would repeat them).
const { params: _params, ...rest } = config;
return {
return applyBuildPolicyToCommand({
...rest,
kind: "gradle",
command: ctx.gradle.command,
@@ -83,7 +89,7 @@ export function delegatedGradleConfig(config: RunConfig, module: string, ctx: De
// The Gradle JVM's JAVA_HOME first; the configuration's own environment wins.
env: { ...ctx.gradle.env, ...config.env },
module: module,
};
});
}
/**
@@ -148,8 +154,9 @@ export async function gradleDelegation(config: RunConfig): Promise<RunConfig | n
if (!project?.root || settings?.languages?.java?.gradle?.runUsing === "ide") return null;
const { model, gradleOffline } = useBuildModel.getState();
if (!model?.gradle?.length) return null;
const module = config.module ?? (await findJavaModule(config));
if (module === null) return null;
const module = await prepareJavaModule(config, async () => {
await maybeEditorService()?.saveAll();
});
const dir = absolute(project.root, module);
// A composite can report a sibling's module; prefer a linked root that contains it.
const ownerDepth = (g: GradleModel) => {
@@ -158,7 +165,7 @@ export async function gradleDelegation(config: RunConfig): Promise<RunConfig | n
return path === root || path.startsWith(`${root}/`) ? root.split("/").length : 0;
};
const gradle = model.gradle
.filter((g) => g.projects.some((p) => absolute(project.root, p.dir) === dir))
.filter((g) => gradleContainsModule(g, project.root, module))
.sort((a, b) => ownerDepth(b) - ownerDepth(a))[0];
if (!gradle) return null;
const ctx = {
+12 -1
Переглянути файл
@@ -137,7 +137,18 @@ async function debugConfig(config: RunConfig, chain: readonly string[] = [], sho
return;
}
// "Build and run using: Gradle": the JavaExec JVM gets the agent, the debugger attaches to it.
const delegated = getAction("debug.attach") ? await gradleDelegation(config) : null;
let delegated: RunConfig | null;
try {
delegated = getAction("debug.attach") ? await gradleDelegation(config) : null;
} catch (error) {
notifications().notify({
group: "run",
level: "error",
title: `Cannot run '${config.name}'`,
body: error instanceof Error ? error.message : String(error),
});
return;
}
if (delegated) {
await debugWithAgent(delegated, show);
return;
+6
Переглянути файл
@@ -366,6 +366,12 @@ describe("debugging build tool configurations", () => {
expect(out.env.MAVEN_OPTS).toMatch(AGENT);
});
it("honours Rebuild on a Maven debug command", () => {
const out = jdwpLaunch(cfg({ id: "r", kind: "maven", args: ["spring-boot:run"], buildPolicy: "rebuild" }))!;
expect(out.args[0]).toBe("clean");
expect(out.args.at(-1)).toMatch(/^-Dspring-boot\.run\.jvmArguments=-agentlib:jdwp=/);
});
it("keeps plain Maven builds out of the debugger unless set to the Java debugger", () => {
expect(jdwpLaunch(cfg({ id: "p", kind: "maven", args: ["exec:exec"] }))).toBeNull();
const chosen = jdwpLaunch(cfg({ id: "p", kind: "maven", args: ["package"], debugAdapter: "java-debug" }))!;
+2
Переглянути файл
@@ -11,6 +11,7 @@
import { executeAction } from "@/platform/actions";
import type { RunConfig } from "@/platform/ipc";
import { isJvmApplication, JDWP_AGENT, jdwpListeningPort } from "@/platform/javaLaunch";
import { applyBuildPolicyToCommand } from "./buildCommand";
import { startRun, useRun } from "./store";
import { DELEGATED_DEBUG, DELEGATED_MAIN } from "./gradleRun";
@@ -38,6 +39,7 @@ function withProperty(args: readonly string[], key: string, value: string): stri
/** The configuration with a waiting JDWP agent, or null when it starts no debuggable JVM. */
export function jdwpLaunch(config: RunConfig): RunConfig | null {
config = applyBuildPolicyToCommand(config);
if (config.kind === "tomcat") return { ...config, params: { ...config.params, tomcatDebug: "true" } };
const args = config.args;
// A single source file (`java Hi.java`): the agent goes before the file. Compiled first
+70 -6
Переглянути файл
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { IpcError, type RunConfig } from "@/platform/ipc";
import { findJavaModule, prepareJavaLaunch } from "@/platform/javaLaunch";
import { useProject } from "@/platform/project";
import { findJavaModule, prepareJavaLaunch, prepareJavaModule } from "@/platform/javaLaunch";
import { _resetI18nForTests, setLocale } from "@/platform/i18n";
import { provideNotificationService } from "@/workbench/contributions";
import { applicationConfig } from "./application";
@@ -72,7 +73,7 @@ afterEach(() => {
it.each(["sibling", "/outside/modules"])("runs from %s using the stored module's classpath", async (cwd) => {
const session = await startRun({ ...config, module: "app", cwd });
expect(session?.status).toBe("running");
expect(ipc.javaResolveLaunch).toHaveBeenCalledWith("app", false, true, expect.any(Number));
expect(ipc.javaResolveLaunch).toHaveBeenCalledWith("app", false, "automatic", expect.any(Number));
expect(ipc.runStart).toHaveBeenCalledWith(
expect.objectContaining({ cwd, args: ["-cp", "/p/app/target/classes", "a.Main"] }),
expect.anything(),
@@ -80,10 +81,59 @@ it.each(["sibling", "/outside/modules"])("runs from %s using the stored module's
expect(ipc.findFiles).not.toHaveBeenCalled();
});
const launchInfo = (build: "succeeded" | "failed" | "withErrors") => ({
build,
projectRoot: "/p",
classPaths: ["/p/app/target/classes"],
modulePaths: [],
javaHome: null,
javaExec: "java",
shortenCommandLine: null,
errors: build === "succeeded" ? [] : [{ path: "/p/app/src/Main.java", line: 1, column: 1, message: "boom" }],
errorCount: build === "succeeded" ? 0 : 1,
hints: [],
});
it.each(["noBuild", "rebuild"] as const)("passes the %s build policy to the resolver", async (buildPolicy) => {
await startRun({ ...config, module: "app", buildPolicy });
expect(ipc.javaResolveLaunch).toHaveBeenCalledWith("app", false, buildPolicy, expect.any(Number));
});
it("always applies the launch policy after a Before launch Build Project", async () => {
useProject.setState({ project: { root: "/p" } as never });
await startRun({ ...config, module: "app" });
expect(ipc.javaResolveLaunch).toHaveBeenCalledWith("app", false, "automatic", expect.any(Number));
useProject.setState({ project: null });
});
it("does not start a configuration whose build failed", async () => {
ipc.javaResolveLaunch.mockResolvedValueOnce({ status: "ok", data: launchInfo("failed") });
const session = await startRun({ ...config, module: "app", buildPolicy: "make" });
expect(session?.status).toBe("failed");
expect(ipc.runStart).not.toHaveBeenCalled();
});
it("starts with the last compiled classes for makeAndContinueOnErrors", async () => {
ipc.javaResolveLaunch.mockResolvedValueOnce({ status: "ok", data: launchInfo("withErrors") });
const session = await startRun({ ...config, module: "app", buildPolicy: "makeAndContinueOnErrors" });
expect(session?.status).toBe("running");
expect(ipc.runStart).toHaveBeenCalled();
});
it.each([undefined, "automatic", "make", "rebuild"] as const)(
"does not launch scoped compilation-error bytecode under %s",
async (buildPolicy) => {
ipc.javaResolveLaunch.mockResolvedValueOnce({ status: "ok", data: launchInfo("withErrors") });
const session = await startRun({ ...config, module: "app", buildPolicy });
expect(session?.status).toBe("failed");
expect(ipc.runStart).not.toHaveBeenCalled();
},
);
it("finds the source module for an old saved configuration without a module", async () => {
await startRun({ ...config, cwd: "/outside/modules" });
expect(ipc.findFiles).toHaveBeenCalledWith("Main.java", 1000);
expect(ipc.javaResolveLaunch).toHaveBeenCalledWith("app", false, true, expect.any(Number));
expect(ipc.javaResolveLaunch).toHaveBeenCalledWith("app", false, "automatic", expect.any(Number));
expect(ipc.runStart).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/outside/modules" }), expect.anything());
});
@@ -96,7 +146,7 @@ it.each([["-cp", "lib/*"], []])("colours Spring Boot runs without classpath reso
expect.objectContaining({ args: ["-Dspring.output.ansi.enabled=always", ...args] }),
expect.anything(),
);
expect(ipc.javaResolveLaunch).not.toHaveBeenCalled();
expect(ipc.javaResolveLaunch).toHaveBeenCalledWith("app", false, "automatic", expect.any(Number));
expect(boot.args).toBe(args);
expect(args).not.toContain("-Dspring.output.ansi.enabled=always");
});
@@ -105,14 +155,14 @@ it.each(["never", "detect", "always"])("preserves an explicit Spring Boot consol
const args = [`-Dspring.output.ansi.enabled=${value}`, "-cp", "lib/*", "a.Main"];
await startRun({ ...config, kind: "spring-boot", args });
expect(ipc.runStart).toHaveBeenCalledWith(expect.objectContaining({ args }), expect.anything());
expect(ipc.javaResolveLaunch).not.toHaveBeenCalled();
expect(ipc.javaResolveLaunch).toHaveBeenCalledWith("app", false, "automatic", expect.any(Number));
});
it("keeps plain Java runs with an explicit classpath free of Spring Boot options", async () => {
const args = ["-cp", "lib/*", "a.Main"];
await startRun({ ...config, args });
expect(ipc.runStart).toHaveBeenCalledWith(expect.objectContaining({ args }), expect.anything());
expect(ipc.javaResolveLaunch).not.toHaveBeenCalled();
expect(ipc.javaResolveLaunch).toHaveBeenCalledWith("app", false, "automatic", expect.any(Number));
});
it("adds the Spring Boot console option once when resolving the module classpath", async () => {
@@ -197,3 +247,17 @@ it("translates import recovery guidance for a root Spring Boot configuration", a
),
).rejects.toThrow("Java 模块 '.' 尚未导入");
});
it("does not resolve or launch when saving fails", async () => {
const saveAll = vi.fn().mockRejectedValue(new Error("save failed"));
await expect(
prepareJavaLaunch(config, {
request: 1,
signal: new AbortController().signal,
saveAll,
}),
).rejects.toThrow("save failed");
await expect(prepareJavaModule(config, saveAll)).rejects.toThrow("save failed");
expect(ipc.javaResolveLaunch).not.toHaveBeenCalled();
expect(ipc.runStart).not.toHaveBeenCalled();
});
+61
Переглянути файл
@@ -1,3 +1,4 @@
import { useBuildModel } from "@/platform/buildModel";
import { useBuildPresentation } from "@/platform/projectPresentation";
import { useProject } from "@/platform/project";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -116,6 +117,22 @@ describe("configuration types", () => {
expect(formParams(saved)).toMatchObject({ mainClass: "a.Main", module: "." });
});
it("sets the before launch build policy of a Java configuration", async () => {
const type = typeFor("application")!;
const config = withParams(newConfigOfType(type, []), {
...newConfigOfType(type, []).params,
mainClass: "a.Main",
});
const changed = vi.fn();
render(<ConfigForm config={config} all={[config]} type={type} errors={{}} onChange={changed} modifyRequest={0} />);
fireEvent.click(screen.getByRole("button", { name: /Modify options/ }));
fireEvent.click(await screen.findByText("Build"));
expect(screen.getByRole("button", { name: "Build" }).textContent).toContain("Automatic");
fireEvent.click(screen.getByRole("button", { name: "Build" }));
fireEvent.click(await screen.findByRole("option", { name: "Rebuild" }));
expect(changed).toHaveBeenCalledWith(expect.objectContaining({ buildPolicy: "rebuild" }));
});
it.each(["spring-boot", "tomcat"])("edits shared update policies in the %s form", async (kind) => {
const type = typeFor(kind)!;
const original = newConfigOfType(type, []);
@@ -700,6 +717,16 @@ describe("before launch tasks", () => {
useBuildPresentation.setState({ modules: new Map() });
disposeBuild();
});
it("shows the build policy for a Maven configuration", async () => {
const type = typeFor("maven")!;
const config = newConfigOfType(type, []);
const changed = vi.fn();
render(<ConfigForm config={config} all={[config]} type={type} errors={{}} onChange={changed} modifyRequest={0} />);
fireEvent.click(screen.getByRole("button", { name: /Modify options/ }));
fireEvent.click(await screen.findByText("Build"));
expect(screen.getByRole("button", { name: "Build" })).toBeTruthy();
});
});
describe("environment variables", () => {
@@ -866,3 +893,37 @@ describe("new Gradle wrapper default", () => {
},
);
});
it.each(["maven", "gradle", "delegated", "absolute", "legacy"])(
"offers only supported build policies for %s",
(tool) => {
const kind = tool === "maven" || tool === "gradle" ? tool : "application";
const config = cfg("main", { kind, module: tool === "legacy" ? null : "app", buildPolicy: "make" });
const previous = useProject.getState();
if (kind === "application") {
useProject.setState({ project: { root: "/work" } as never, settings: null });
useBuildModel.setState({
model: { gradle: [{ projects: [{ dir: tool === "absolute" ? "/work/app" : "app" }] }] } as never,
});
}
try {
render(
<ConfigForm
config={config}
all={[config]}
type={typeFor(kind)}
errors={{}}
onChange={vi.fn()}
modifyRequest={0}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Build" }));
expect(screen.queryByRole("option", { name: "No build" })).toBeNull();
expect(screen.queryByRole("option", { name: "Build, no error check" }) !== null).toBe(tool === "maven");
expect(screen.getByRole("option", { name: "Rebuild" })).toBeTruthy();
} finally {
useBuildModel.setState({ model: null });
useProject.setState(previous);
}
},
);
+10 -3
Переглянути файл
@@ -19,9 +19,9 @@ import {
type TestCaseReport,
} from "@/platform/ipc";
import {
consoleVmArgs,
findJavaModule,
isJvmApplication,
JavaBuildFailedError,
parseJavaArgs,
prepareJavaLaunch,
} from "@/platform/javaLaunch";
@@ -30,6 +30,7 @@ import { useProject } from "@/platform/project";
import { executeAction } from "@/platform/actions";
import { workflowSettings } from "@/platform/workflowSettings";
import { maybeEditorService, notifications, toolWindowService, useDebugRunSessions } from "@/workbench/contributions";
import { applyBuildPolicyToCommand } from "./buildCommand";
import { ConsoleModel } from "./console";
import { jvmFrameTarget } from "./links";
import { confirmRerun, confirmTerminate } from "./dialogs";
@@ -851,6 +852,8 @@ export async function startRun(
};
try {
let launch = options.launch ?? config;
// A Maven/Gradle configuration's own command is its build; Rebuild adds clean.
if (!options.launch) launch = applyBuildPolicyToCommand(launch);
// "Build and run using: Gradle": the module's Gradle build compiles and launches it.
const delegated = isJvmApplication(launch) ? await gradleDelegation(launch) : null;
if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null;
@@ -931,7 +934,8 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom
say(`No main class in '${config.name}': expected java [options] -cp \${classpath} <main class> [args]\n`, "stderr");
return null;
}
if (!parsed.classpath) return { ...config, args: [...consoleVmArgs(config), ...config.args] };
// Always prepare, exactly like Debug: even an explicit classpath needs the module, the
// project JDK and the selected build, and both modes must share the same result.
const rt = runtime(sessionId)!;
const request = { id: nextRequestId(), controller: new AbortController() };
rt.resolveRequest = request;
@@ -940,6 +944,9 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom
request: request.id,
signal: request.controller.signal,
report: say,
saveAll: async () => {
await maybeEditorService()?.saveAll();
},
});
// No installed JDK is new enough for the module: offer to download one.
const jdkHint = info.hints.find((h) => downloadJdkActions(h).length);
@@ -959,7 +966,7 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom
if (runtimes.get(sessionId) === rt && rt.resolveRequest === request) patchSession(sessionId, { stopped: true });
return null;
}
say(`Cannot resolve the classpath: ${message}\n`, "stderr");
say(e instanceof JavaBuildFailedError ? `${message}\n` : `Cannot resolve the classpath: ${message}\n`, "stderr");
// jdtls can't start without a newer JDK: "Download JDK 25".
const actions = downloadJdkActions(message);
notifications().notify({
+1 -1
Переглянути файл
@@ -278,7 +278,7 @@ describe("updating a running Tomcat deployment", () => {
it("compiles the selected WAR module and copies its outputs instead of rebuilding the WAR", async () => {
const c = config({ deployment: "artifact", artifact: "maven:web/pom.xml" });
expect(await syncTomcatOutputs(c, false)).toBe(true);
expect(commands.javaResolveLaunch).toHaveBeenCalledWith("web", false, true, expect.anything());
expect(commands.javaResolveLaunch).toHaveBeenCalledWith("web", false, "make", expect.anything());
expect(commands.runTomcatSync).toHaveBeenCalledWith(c, "web", ["/p/web/target/classes", "/m2/lib.jar"], false);
});
+3 -1
Переглянути файл
@@ -161,7 +161,9 @@ export async function syncTomcatOutputs(config: RunConfig, resourcesOnly: boolea
const artifacts = await call(commands.runTomcatArtifacts());
const artifact = p.artifact ? artifacts.find((a) => a.id === p.artifact) : artifacts[0];
if (!artifact) return false;
const info = await call(commands.javaResolveLaunch(artifact.module, false, true, nextRequestId())).catch(() => null);
const info = await call(commands.javaResolveLaunch(artifact.module, false, "make", nextRequestId())).catch(
() => null,
);
if (!info) return false;
if (info.build === "failed" || info.build === "withErrors" || info.build === "cancelled")
throw new Error(tx("Application update build failed"));