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 { firstTopLevelType, jvmMainCandidates, scanJvmTargets } from "./jvmTargets"; import { adoptTemporary, MAX_TEMPORARY, saveTemporary, testReportEvents, useRun } from "./store"; import { detectRunTargets, targetConfig } from "./targets"; const cfg = (over: Partial & { 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], ]); }); it("treats the public void methods of a TestNG class-level @Test as tests", () => { const src = [ "package a.b;", // 0 "import org.testng.annotations.*;", '@Test(groups = "unit")', "public class PriceTest {", // 3 " @BeforeMethod public void setUp() {}", " public void rounds() {}", // 5 ' @Test(dataProvider = "rows")', " public void parses(String row) {}", // 7 " @DataProvider public Object[][] rows() { return null; }", ' @DataProvider(name = "more") public void notATest() {}', " void packagePrivate() {}", " private void util() {}", " public static void shared() {}", " public int value() { return 1; }", "}", ].join("\n"); expect(pick(src, "src/test/java/a/b/PriceTest.java")).toEqual([ [3, "suite", "a.b.PriceTest", null, false], [5, "test", "a.b.PriceTest", "rounds", false], [7, "test", "a.b.PriceTest", "parses", false], ]); }); }); describe("firstTopLevelType", () => { it("is the first top-level class, interface, enum, record or annotation, with the package", () => { const src = [ "package demo;", "import java.util.List; // class Commented {}", '/* enum Old {} */ @Tag(type = List.class, name = "class Str")', "interface Shape { class Nested {} }", "public class Test { public static void main(String[] args) {} }", ].join("\n"); expect(firstTopLevelType(src)).toBe("demo.Shape"); expect(firstTopLevelType("record Point(int x) {}\nclass Test {}")).toBe("Point"); expect(firstTopLevelType("@interface Marker {}\nclass Test {}")).toBe("Marker"); expect(firstTopLevelType("void main() {\n IO.println(1);\n}\n")).toBeNull(); }); }); 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) {}", "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) {}", // 6 " }", "}", "object Cli { @JvmStatic fun main(args: Array) {} }", // 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("takes the innermost module the build model knows, like IDEA's file index", () => { const modules = ["", "/w/billing", "/w/billing/modules/core", "legacy"]; // `sourceSets.main.java.srcDirs = ['src']`: no `src/main/java` to guess from. expect(jvmModuleDir("legacy/src/a/App.java", [], modules)).toBe("legacy"); expect(jvmModuleDir("/w/billing/modules/core/src/main/java/a/App.java", [], modules)).toBe( "/w/billing/modules/core", ); // A module the model does not know yet (before the first sync): the source layout. expect(jvmModuleDir("services/api/src/main/java/a/App.java", [], modules)).toBe("services/api"); }); it("adds JDWP to a script explicitly assigned the Kotlin debugger", () => { const rc = cfg({ id: "kotlin-script", kind: "shell", command: "./run.sh", debugAdapter: "kotlin-debug-adapter" }); expect(jdwpLaunch(rc)?.env.JAVA_TOOL_OPTIONS).toContain("-agentlib:jdwp="); }); it("routes Kotlin facade and @file:JvmName Application configurations to Kotlin", () => { for (const className of ["org.example.MainKt", "org.example.CustomFacade"]) { expect(applicationConfig({ className }, "src/main/kotlin/Main.kt")).toMatchObject({ debugAdapter: "kotlin-debug-adapter", args: ["-cp", "${classpath}", className], }); } }); 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", module: "m", cwd: null, 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 = { build: "withErrors", projectRoot: "/p", classPaths: [], modulePaths: [], pathListSeparator: ":", 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: [], pathListSeparator: ":", 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"], pathListSeparator: ";" }).args.slice(0, 2), ).toEqual(["--module-path", "/m/x.jar"]); }); it("colours a Spring Boot console unless the configuration sets the property", () => { const boot = (args: string[]) => ({ ...applicationConfig({ className: "a.Main" }, "src/main/java/a/Main.java"), kind: "spring-boot" as const, args, }); expect(resolveJavaCommand(boot(["-Xmx1g", "a.Main"]), info).args[0]).toBe("-Dspring.output.ansi.enabled=always"); expect(resolveJavaCommand(boot(["-Dspring.output.ansi.enabled=never", "a.Main"]), info).args).toEqual([ "-Dspring.output.ansi.enabled=never", "a.Main", ]); const plain = applicationConfig({ className: "a.Main" }, "src/main/java/a/Main.java"); expect(resolveJavaCommand(plain, info).args).not.toContain("-Dspring.output.ansi.enabled=always"); }); 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(); }); const AGENT = /-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127\.0\.0\.1:0$/; it.each([ ["tomcat7:run"], ["org.apache.tomcat.maven:tomcat7-maven-plugin:2.2:run"], ["jetty:run"], ["jetty:run-war"], ["exec:java"], ])("debugs Maven's own JVM for the in-process goal %s", (goal) => { const out = jdwpLaunch(cfg({ id: "m", kind: "maven", args: ["clean", goal], env: { MAVEN_OPTS: "-Xmx1g" } }))!; expect(out.args).toEqual(["clean", goal]); expect(out.env.MAVEN_OPTS).toMatch(/^-Xmx1g -agentlib:jdwp=/); expect(out.env.MAVEN_OPTS).toMatch(AGENT); }); 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" }))!; expect(chosen.env.MAVEN_OPTS).toMatch(AGENT); }); it.each([ ["shell", "C:\\apache-tomcat-9.0.98\\bin\\catalina.bat", ["run"]], ["shell", "/opt/tomcat9/bin/catalina.sh", ["run"]], ["program", "java", ["-jar", "app.war"]], ])("starts a %s configuration set to the Java debugger with an agent in JAVA_TOOL_OPTIONS", (kind, command, args) => { const rc = cfg({ id: "tomcat9", kind, command, args, debugAdapter: "java-debug" }); const out = jdwpLaunch(rc)!; expect(out.command).toBe(command); expect(out.args).toEqual(args); expect(out.env.JAVA_TOOL_OPTIONS).toMatch(AGENT); const merged = jdwpLaunch({ ...rc, env: { JAVA_TOOL_OPTIONS: "-Dfile.encoding=UTF-8" } })!; expect(merged.env.JAVA_TOOL_OPTIONS).toMatch(/^-Dfile\.encoding=UTF-8 -agentlib:jdwp=/); }); it("debugs a single Java file: the agent before the file, or for java only on a javac line", () => { const source = jdwpLaunch(cfg({ id: "f", kind: "java-file", command: "/bin/java", args: ["Hi.java"] }))!; expect(source.args).toEqual([expect.stringMatching(AGENT), "Hi.java"]); const line = "mkdir -p /tmp/c && /bin/javac -g -d /tmp/c Test.java && /bin/java -cp /tmp/c Test"; const compiled = jdwpLaunch( cfg({ id: "f", kind: "java-file", command: line, env: { JDK_JAVA_OPTIONS: "-Xmx1g" } }), )!; expect(compiled).toMatchObject({ command: line, args: [] }); expect(compiled.env.JDK_JAVA_OPTIONS).toMatch(/^-Xmx1g -agentlib:jdwp=/); expect(compiled.env.JAVA_TOOL_OPTIONS).toBeUndefined(); }); it("leaves Application configurations and other debuggers alone", () => { for (const kind of ["application", "spring-boot"]) expect(jdwpLaunch(cfg({ id: "a", kind, command: "java", debugAdapter: "java-debug" }))).toBeNull(); expect(jdwpLaunch(cfg({ id: "s", kind: "shell", command: "./run.sh", debugAdapter: "debugpy" }))).toBeNull(); expect(jdwpLaunch(cfg({ id: "g", kind: "gradle", args: ["build"], debugAdapter: "java-debug" }))).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" }, }); }); it("locate Kotlin tests with spaces in their names, behind a class loader prefix", () => { const events = testReportEvents([ { className: "a.LibraryTest", name: "cannot lend twice", status: "failed", durationMs: null, message: null, output: "AssertionFailedError\n\tat app//a.LibraryTest.cannot lend twice(LibraryTest.kt:43)", }, ]); expect(events).toContainEqual({ type: "location", path: ["a.LibraryTest", "cannot lend twice"], location: { path: "a/LibraryTest.kt", line: 43, resolve: "name", className: "a.LibraryTest" }, }); }); }); 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.v2:/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("reuse a configuration of the same main class and module, whatever its id", () => { const own = (module: string) => cfg({ id: `user:${module}`, kind: "spring-boot", name: `Mine ${module}`, args: ["-Xmx1g", "-cp", "${classpath}", "a.Main"], module, detected: false, }); useRun.setState({ configs: [own("other"), own("app")] }); expect(adoptTemporary(applicationConfig({ className: "a.Main" }, "app/src/main/java/a/Main.java")).name).toBe( "Mine app", ); expect(useRun.getState().temporary).toEqual([]); const fresh = adoptTemporary(applicationConfig({ className: "a.Other" }, "app/src/main/java/a/Other.java")); expect(useRun.getState().temporary).toEqual([fresh.id]); }); 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([]); }); }); describe("jvmMainCandidates", () => { const names = (src: string) => jvmMainCandidates(src, "a/B.java").map((c) => c.className); it("finds the class from the report, at its name", () => { const src = "package demo;\n\n@SpringBootApplication\npublic class AiChatApplication extends AtomStarter {}\n"; expect(jvmMainCandidates(src, "a/B.java")).toEqual([ { line: 3, col: 13, className: "demo.AiChatApplication", base: "AtomStarter", spring: true }, ]); }); it.each([ ["static nested class", "class A { static class B extends Base {} }", ["A$B"]], ["inner (non-static) class", "class A { class B extends Base {} }", []], ["abstract class", "abstract class A extends Base {}", ["A"]], ["own main", "class A extends Base { public static void main(String[] a) {} }", []], ["implements only", "class A implements Runnable {}", []], ["interface extends", "interface A extends Base {}", []], ["enum and record", "enum E implements I {}\nrecord R() implements I {}", []], ["multi-line generic header", "class A\n extends Base\n{}", ["A"]], ["type bound only", "class A {}", []], ["extends in comment and string", '// class A extends B\nclass C { String s = "class D extends E {"; }', []], ])("%s", (_, src, expected) => expect(names(src)).toEqual(expected)); it("accepts java.lang.String[] and flags instance main", () => { const [fq] = scanJvmTargets("class A { public static void main(java.lang.String[] a) {} }", "A.java", false); expect(fq).toMatchObject({ kind: "main", className: "A" }); const [inst] = scanJvmTargets("class A { void main() {} }", "A.java", false); expect(inst).toMatchObject({ kind: "main", instance: true }); expect(fq?.instance).toBeUndefined(); }); });