mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
Root cause: every java launch inlined the whole classpath into one -cp argument with no "Shorten command line", so long classpaths exceeded CreateProcessW's 32767-char limit (os error 206) before the JVM started. Fix (IDEA's auto "Shorten command line"): ide-run rewrites a too-long java command line at spawn time — @argfile on JDK 9+ with ASCII paths, otherwise a MANIFEST pathing jar (percent-encoded file: URLs, 72-byte lines); temp files are removed when the process exits. Debug passes the same choice to java-debug's shortenCommandLine. Siblings covered: Application/Spring Boot, test classpath, Kotlin main, explicit java -cp configs, profiler, PTY launches, Debug. Verified: unit tests, pathing jar loaded by real JDK 8/11/25, ProcessManager integration test with 3000 entries on JDK 8/17/25. Not verified on Windows. Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
332 lines
14 KiB
TypeScript
332 lines
14 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import type { JavaLaunchInfo, RunConfig } from "@/platform/ipc";
|
|
import {
|
|
formatCompileErrors,
|
|
joinJavaArgs,
|
|
jdwpListeningPort,
|
|
parseJavaArgs,
|
|
resolveJavaCommand,
|
|
} from "@/platform/javaLaunch";
|
|
import { useProject } from "@/platform/project";
|
|
import { applicationConfig, jvmModuleDir, jvmTestConfig } from "./application";
|
|
import { buildRows } from "./ConfigChooser";
|
|
import { jdwpLaunch } from "./javaDebug";
|
|
import { scanJvmTargets } from "./jvmTargets";
|
|
import { adoptTemporary, MAX_TEMPORARY, saveTemporary, testReportEvents, useRun } from "./store";
|
|
import { detectRunTargets, targetConfig } from "./targets";
|
|
|
|
const cfg = (over: Partial<RunConfig> & { id: string; kind: string }): RunConfig => ({
|
|
name: over.id,
|
|
command: over.kind === "maven" ? "./mvnw" : over.kind,
|
|
args: [],
|
|
cwd: null,
|
|
env: {},
|
|
detected: true,
|
|
isTest: false,
|
|
debugAdapter: null,
|
|
...over,
|
|
});
|
|
|
|
const pick = (src: string, rel: string, kotlin = false) =>
|
|
scanJvmTargets(src, rel, kotlin).map((t) => [t.line, t.kind, t.className, t.method ?? null, t.spring ?? false]);
|
|
|
|
describe("scanJvmTargets (Java)", () => {
|
|
it("finds a Spring Boot main with annotations on earlier lines", () => {
|
|
const src = [
|
|
"package com.example.demo;", // 0
|
|
"",
|
|
"import org.springframework.boot.SpringApplication;",
|
|
"",
|
|
"@SpringBootApplication(", // 4
|
|
' scanBasePackages = "com.example" // not a ) here',
|
|
")",
|
|
"public class SpringDemoApplication {", // 7
|
|
" /* public static void main(String[] args) in a comment */",
|
|
' static String s = "void main(String[] a) {";',
|
|
" public static void", // 10
|
|
" main(",
|
|
" final String... args) {",
|
|
" SpringApplication.run(SpringDemoApplication.class, args);",
|
|
" }",
|
|
"}",
|
|
].join("\n");
|
|
expect(pick(src, "src/main/java/com/example/demo/SpringDemoApplication.java")).toEqual([
|
|
[11, "main", "com.example.demo.SpringDemoApplication", null, true],
|
|
]);
|
|
});
|
|
|
|
it("ignores non-main methods named main and Java 25 instance mains count", () => {
|
|
expect(pick("class A {\n int main(String[] a) { return 0; }\n static void main(int x) {}\n}", "A.java")).toEqual([]);
|
|
expect(pick("class B {\n void main() {}\n}", "B.java")).toEqual([[1, "main", "B", null, false]]);
|
|
});
|
|
|
|
it("finds JUnit tests, nested @Nested classes and multi-line annotations", () => {
|
|
const src = [
|
|
"package a.b;", // 0
|
|
"class OrderTest {", // 1
|
|
" @Test",
|
|
" void plain() {}", // 3
|
|
" @ParameterizedTest(",
|
|
' name = "{0}")',
|
|
" @ValueSource(ints = {1, 2})",
|
|
" void param(int x) {", // 7
|
|
" Runnable r = new Runnable() { public void run() {} };",
|
|
" }",
|
|
" void helper() {}",
|
|
" @Nested",
|
|
" class WhenEmpty {", // 12
|
|
" @org.junit.jupiter.api.Test void fails() {}", // 13
|
|
" }",
|
|
" static class Fixture {",
|
|
" @Test void inner() {}", // 16
|
|
" }",
|
|
"}",
|
|
].join("\n");
|
|
expect(pick(src, "src/test/java/a/b/OrderTest.java")).toEqual([
|
|
[1, "suite", "a.b.OrderTest", null, false],
|
|
[3, "test", "a.b.OrderTest", "plain", false],
|
|
[7, "test", "a.b.OrderTest", "param", false],
|
|
[12, "suite", "a.b.OrderTest$WhenEmpty", null, false],
|
|
[13, "test", "a.b.OrderTest$WhenEmpty", "fails", false],
|
|
[15, "suite", "a.b.OrderTest$Fixture", null, false],
|
|
[16, "test", "a.b.OrderTest$Fixture", "inner", false],
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("scanJvmTargets (Kotlin)", () => {
|
|
it("top-level main runs the file facade, objects and companions with @JvmStatic", () => {
|
|
expect(pick("package app\n\nfun main(args: Array<String>) {}", "src/main/kotlin/app/server.kt", true)).toEqual([
|
|
[2, "main", "app.ServerKt", null, false],
|
|
]);
|
|
expect(pick('@file:JvmName("Boot")\npackage app\nfun main() {}', "src/main/kotlin/app/x.kt", true)).toEqual([
|
|
[2, "main", "app.Boot", null, false],
|
|
]);
|
|
const companion = [
|
|
"package app",
|
|
"@SpringBootApplication",
|
|
"class Demo",
|
|
"class Tool {",
|
|
" companion object {",
|
|
" @JvmStatic",
|
|
" fun main(args: Array<String>) {}", // 6
|
|
" }",
|
|
"}",
|
|
"object Cli { @JvmStatic fun main(args: Array<String>) {} }", // 9
|
|
].join("\n");
|
|
expect(pick(companion, "src/main/kotlin/app/Tool.kt", true)).toEqual([
|
|
[6, "main", "app.Tool", null, false],
|
|
[9, "main", "app.Cli", null, false],
|
|
]);
|
|
});
|
|
|
|
it("finds backticked test names", () => {
|
|
const kt = "class CalcTest {\n @Test\n fun `adds numbers`() {}\n}";
|
|
expect(pick(kt, "src/test/kotlin/CalcTest.kt", true)).toEqual([
|
|
[0, "suite", "CalcTest", null, false],
|
|
[2, "test", "CalcTest", "adds numbers", false],
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("application configurations", () => {
|
|
const maven = [cfg({ id: "maven:test", kind: "maven", args: ["test"], isTest: true })];
|
|
|
|
it("derives the module directory", () => {
|
|
expect(jvmModuleDir("src/main/java/a/App.java")).toBe("");
|
|
expect(jvmModuleDir("services/api/src/test/kotlin/a/T.kt")).toBe("services/api");
|
|
expect(jvmModuleDir("odd/App.java", [cfg({ id: "m", kind: "maven", cwd: "odd" })])).toBe("odd");
|
|
});
|
|
|
|
it("creates an Application / Spring Boot config named after the class", () => {
|
|
const rel = "src/main/java/com/example/demo/SpringDemoApplication.java";
|
|
const src = "package com.example.demo;\n@SpringBootApplication\nclass SpringDemoApplication {\n public static void main(String[] a) {}\n}";
|
|
const main = detectRunTargets(src, rel).find((t) => t.kind === "main")!;
|
|
const c = targetConfig(main, rel, maven, src)!;
|
|
expect(c).toMatchObject({
|
|
id: "application:com.example.demo.SpringDemoApplication",
|
|
name: "SpringDemoApplication",
|
|
kind: "spring-boot",
|
|
command: "java",
|
|
args: ["-cp", "${classpath}", "com.example.demo.SpringDemoApplication"],
|
|
cwd: null,
|
|
debugAdapter: "java-debug",
|
|
isTest: false,
|
|
});
|
|
expect(applicationConfig({ className: "a.Tool" }, "m/src/test/java/a/Tool.java")).toMatchObject({
|
|
kind: "application",
|
|
cwd: "m",
|
|
args: ["-cp", "${testClasspath}", "a.Tool"],
|
|
});
|
|
});
|
|
|
|
it("scopes Maven and Gradle test configs to a class or method", () => {
|
|
const file = cfg({ id: "f", kind: "maven", args: ["test", "-Dtest=OrderTest"], isTest: true });
|
|
expect(jvmTestConfig(file, "a.OrderTest$Nested", "fails")).toMatchObject({
|
|
name: "Nested.fails",
|
|
args: ["test", "-Dtest=a.OrderTest$Nested#fails", "-Dsurefire.failIfNoSpecifiedTests=false"],
|
|
});
|
|
const gradle = cfg({ id: "g", kind: "gradle", args: ["test", "--tests", "*OrderTest"], isTest: true });
|
|
expect(jvmTestConfig(gradle, "a.OrderTest", undefined)!.args).toEqual(["test", "--tests", "a.OrderTest"]);
|
|
});
|
|
});
|
|
|
|
describe("java compile errors", () => {
|
|
const base: Omit<JavaLaunchInfo, "errors" | "errorCount" | "hints"> = {
|
|
build: "withErrors",
|
|
projectRoot: "/p",
|
|
classPaths: [],
|
|
modulePaths: [],
|
|
javaHome: null,
|
|
javaExec: null,
|
|
shortenCommandLine: null,
|
|
};
|
|
|
|
it("lists the first errors, counts the rest and adds hints", () => {
|
|
const report = formatCompileErrors({
|
|
...base,
|
|
errors: [{ path: "/p/src/A.java", line: 72, column: 5, message: "The method getUsername() is undefined" }],
|
|
errorCount: 3,
|
|
hints: ["Install Lombok"],
|
|
});
|
|
expect(report).toBe(
|
|
"/p/src/A.java:72:5: error: The method getUsername() is undefined\n" +
|
|
"… and 2 more errors (see the Problems tool window)\nInstall Lombok\n",
|
|
);
|
|
expect(formatCompileErrors({ ...base, errors: [], errorCount: 0, hints: [] })).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("java command lines", () => {
|
|
const info: JavaLaunchInfo = {
|
|
build: "succeeded",
|
|
projectRoot: "/p",
|
|
classPaths: ["/p/target/classes", "/m2/a.jar"],
|
|
modulePaths: [],
|
|
javaHome: "/jdk21",
|
|
javaExec: "/jdk21/bin/java",
|
|
shortenCommandLine: "none",
|
|
errors: [],
|
|
errorCount: 0,
|
|
hints: [],
|
|
};
|
|
|
|
it("parses VM options, main class and program arguments", () => {
|
|
expect(parseJavaArgs(["-Xmx1g", "--add-opens", "a/b=ALL", "-cp", "${classpath}", "a.Main", "-v", "x"])).toEqual({
|
|
vmArgs: ["-Xmx1g", "--add-opens", "a/b=ALL"],
|
|
mainClass: "a.Main",
|
|
programArgs: ["-v", "x"],
|
|
classpath: "runtime",
|
|
explicitClasspath: null,
|
|
});
|
|
expect(parseJavaArgs(["-cp", "lib/*", "a.Main"]).explicitClasspath).toBe("lib/*");
|
|
expect(parseJavaArgs(["-Dx=1"]).mainClass).toBeNull();
|
|
});
|
|
|
|
it("substitutes the classpath and the project JDK", () => {
|
|
const c = applicationConfig({ className: "a.Main" }, "src/main/java/a/Main.java");
|
|
const r = resolveJavaCommand({ ...c, args: ["-Dx=1", ...c.args, "arg"] }, info);
|
|
expect(r.command).toBe("/jdk21/bin/java");
|
|
expect(r.args).toEqual(["-Dx=1", "-cp", "/p/target/classes:/m2/a.jar", "a.Main", "arg"]);
|
|
expect(resolveJavaCommand(c, { ...info, modulePaths: ["/m/x.jar"] }, ";").args.slice(0, 2)).toEqual([
|
|
"--module-path",
|
|
"/m/x.jar",
|
|
]);
|
|
});
|
|
|
|
it("joins arguments for java-debug and reads JDWP's port", () => {
|
|
expect(joinJavaArgs(["a", "b c", 'q"'])).toBe('a "b c" "q\\""');
|
|
expect(jdwpListeningPort("Listening for transport dt_socket at address: 50123")).toBe(50123);
|
|
expect(jdwpListeningPort("Listening for transport dt_socket at address: 127.0.0.1:5005")).toBe(5005);
|
|
expect(jdwpListeningPort("[INFO] Listening")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("debugging build tool configurations", () => {
|
|
it("adds a waiting JDWP agent to spring-boot:run, Surefire and Gradle", () => {
|
|
const boot = jdwpLaunch(cfg({ id: "b", kind: "maven", args: ["spring-boot:run"] }))!;
|
|
expect(boot.args[1]).toMatch(/^-Dspring-boot\.run\.jvmArguments=-agentlib:jdwp=.*suspend=y.*address=127\.0\.0\.1:0$/);
|
|
const merged = jdwpLaunch(
|
|
cfg({ id: "b", kind: "maven", args: ["spring-boot:run", "-Dspring-boot.run.jvmArguments=-Xmx1g"] }),
|
|
)!;
|
|
expect(merged.args[1]).toMatch(/^-Dspring-boot\.run\.jvmArguments=-Xmx1g -agentlib:jdwp=/);
|
|
const test = jdwpLaunch(cfg({ id: "t", kind: "maven", args: ["test", "-Dtest=A#b"], isTest: true }))!;
|
|
expect(test.args.some((a) => a.startsWith("-Dmaven.surefire.debug=-agentlib:jdwp="))).toBe(true);
|
|
expect(jdwpLaunch(cfg({ id: "g", kind: "gradle", args: ["bootRun"] }))!.args).toEqual(["bootRun", "--debug-jvm"]);
|
|
expect(jdwpLaunch(cfg({ id: "p", kind: "maven", args: ["package"] }))).toBeNull();
|
|
expect(jdwpLaunch(cfg({ id: "n", kind: "npm", args: ["test"] }))).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("JUnit XML reports", () => {
|
|
it("become tree results with failure output and source location", () => {
|
|
const events = testReportEvents([
|
|
{ className: "a.B", name: "ok", status: "passed", durationMs: 3, message: null, output: null },
|
|
{
|
|
className: "a.B$Inner",
|
|
name: "bad",
|
|
status: "failed",
|
|
durationMs: null,
|
|
message: "expected 1",
|
|
output: "AssertionError\n\tat org.junit.Assert.fail(Assert.java:1)\n\tat a.B$Inner.bad(B.java:42)",
|
|
},
|
|
]);
|
|
expect(events[0]).toEqual({ type: "result", path: ["a.B", "ok"], status: "passed", durationMs: 3 });
|
|
expect(events).toContainEqual({ type: "output", path: ["a.B$Inner", "bad"], line: "expected 1" });
|
|
expect(events).toContainEqual({
|
|
type: "location",
|
|
path: ["a.B$Inner", "bad"],
|
|
location: { path: "a/B.java", line: 42, resolve: "name", className: "a.B$Inner" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("temporary configurations", () => {
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
useProject.setState({ project: { root: "/p" } } as never);
|
|
useRun.setState({
|
|
configs: [cfg({ id: "maven:spring-boot:run", kind: "maven" })],
|
|
temporary: [],
|
|
selectedId: null,
|
|
sessions: [],
|
|
});
|
|
});
|
|
|
|
it("are added, selected, capped and persisted", () => {
|
|
const apps = Array.from({ length: MAX_TEMPORARY + 2 }, (_, i) =>
|
|
applicationConfig({ className: `a.App${i}` }, "src/main/java/a/App.java"),
|
|
);
|
|
for (const a of apps) adoptTemporary(a);
|
|
const s = useRun.getState();
|
|
expect(s.temporary).toHaveLength(MAX_TEMPORARY);
|
|
expect(s.temporary).not.toContain(apps[0]!.id);
|
|
expect(s.selectedId).toBe(apps[apps.length - 1]!.id);
|
|
expect(s.configs.filter((c) => c.kind === "application")).toHaveLength(MAX_TEMPORARY);
|
|
expect(JSON.parse(localStorage.getItem("openidea.run.temporary:/p")!)).toHaveLength(MAX_TEMPORARY);
|
|
// Reusing an existing temporary moves it last instead of duplicating it.
|
|
adoptTemporary(apps[3]!);
|
|
expect(useRun.getState().temporary.at(-1)).toBe(apps[3]!.id);
|
|
expect(useRun.getState().configs.filter((c) => c.id === apps[3]!.id)).toHaveLength(1);
|
|
});
|
|
|
|
it("reuse a saved configuration with the same id", () => {
|
|
const saved = cfg({ id: "application:a.Main", kind: "application", name: "Mine", detected: false });
|
|
useRun.setState({ configs: [saved] });
|
|
const used = adoptTemporary(applicationConfig({ className: "a.Main" }, "src/main/java/a/Main.java"));
|
|
expect(used.name).toBe("Mine");
|
|
expect(useRun.getState().temporary).toEqual([]);
|
|
});
|
|
|
|
it("offer Save in the chooser while selected", async () => {
|
|
const app = adoptTemporary(applicationConfig({ className: "a.Main" }, "src/main/java/a/Main.java"));
|
|
const { configs, temporary } = useRun.getState();
|
|
const rows = buildRows(configs, "", app, temporary);
|
|
expect(rows[0]).toMatchObject({ type: "save", config: { id: app.id } });
|
|
expect(buildRows(configs, "x", app, temporary)[0]?.type).not.toBe("save");
|
|
// Not a desktop app in tests: saving fails at the IPC layer but the config stops being temporary.
|
|
await saveTemporary(app.id).catch(() => undefined);
|
|
expect(useRun.getState().temporary).toEqual([]);
|
|
});
|
|
});
|