Files
openidea/src/features/debugger/__tests__/java.test.ts
T
l0ng-ai 379a52d6e7 feat(debugger): Kotlin breakpoints through the Kotlin Debug Adapter (#434)
Kotlin line breakpoints never verified through java-debug because jdtls cannot resolve classes for .kt files. Kotlin Application configurations and JDWP attach for modules with Kotlin sources now use the official Kotlin Debug Adapter (installable from the debugger prompt, running on the language-server JDK); pure Java keeps java-debug.

The adapter resolves classes from source paths, so sources whose directories don't match their package are mirrored package-aligned outside the project (symlinks, copies where links fail) and paths are mapped back for breakpoints and stack frames; files that can't be mirrored are skipped rather than failing the session. Kotlin applications start with JDWP suspend=y and a session-owned JDWP relay holds the adapter's early resume until attach completes, so breakpoints are installed before the program runs.

Limitations (docs/kotlin-debugging.md): @file:JvmName facades are not resolved; package/source-root changes need a new session. Not verified on a real Windows machine.

Fixes #374
2026-10-30 18:15:42 +08:00

147 lines
5.2 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RunConfig } from "@/platform/ipc";
const invoked: Array<[string, unknown[]]> = [];
let shortenCommandLine: string | null = null;
let kotlinFiles = false;
vi.mock("@/platform/ipc", async (orig) => {
const real = await orig<typeof import("@/platform/ipc")>();
const ok = (data: unknown) => Promise.resolve({ status: "ok", data });
return {
...real,
commands: {
...real.commands,
fsReadDir: (path: string) =>
ok(
kotlinFiles && path.endsWith("/src")
? [{ name: "Main.kt", path: `${path}/Main.kt`, kind: "file", excluded: false }]
: [],
),
javaResolveLaunch: (...a: unknown[]) => {
invoked.push(["javaResolveLaunch", a]);
return ok({
build: "succeeded",
projectRoot: "/p",
classPaths: ["/p/target/classes", "/m2/a.jar"],
modulePaths: [],
javaHome: "/jdk21",
javaExec: "/jdk21/bin/java",
shortenCommandLine,
errors: [],
errorCount: 0,
hints: [],
});
},
javaDebugServer: (...a: unknown[]) => {
invoked.push(["javaDebugServer", a]);
return ok(41234);
},
},
};
});
const { isJavaDebuggable, javaAttachConfig, javaLaunchConfig } = await import("../java");
const app: RunConfig = {
id: "application:a.Main",
name: "Main",
kind: "spring-boot",
command: "java",
args: ["-Xmx1g", "-cp", "${classpath}", "a.Main", "--port", "8081 x"],
cwd: "api",
env: { A: "1" },
detected: true,
isTest: false,
debugAdapter: "java-debug",
};
describe("Java debugging (java-debug through jdtls)", () => {
beforeEach(() => {
invoked.length = 0;
shortenCommandLine = null;
kotlinFiles = false;
});
it("launches Application configs with the resolved classpath on jdtls' debug server", async () => {
expect(isJavaDebuggable(app)).toBe(true);
expect(isJavaDebuggable({ ...app, kind: "maven" })).toBe(false);
const l = await javaLaunchConfig(app, "/p");
expect(invoked).toEqual([
["javaResolveLaunch", ["api", false, true]],
["javaDebugServer", ["api"]],
]);
expect(l).toMatchObject({ adapter: "java-debug", request: "launch", cwd: "/p/api", args: [], env: { A: "1" } });
expect(JSON.parse(l.extraJson!)).toMatchObject({
debugServer: 41234,
mainClass: "a.Main",
classPaths: ["/p/target/classes", "/m2/a.jar"],
javaExec: "/jdk21/bin/java",
vmArgs: "-Xmx1g",
args: '--port "8081 x"',
console: "internalConsole",
shortenCommandLine: "none",
});
});
it("passes the configuration's VM options to java-debug's vmArgs, before the main class", async () => {
const args = [
"-Ddemo.flag=on",
"--add-opens",
"java.base/java.lang=ALL-UNNAMED",
"-Dname=a b",
"-cp",
"${classpath}",
];
const l = await javaLaunchConfig({ ...app, args: [...args, "a.App", "--spring.profiles.active=dev"] }, "/p");
expect(JSON.parse(l.extraJson!)).toMatchObject({
mainClass: "a.App",
vmArgs: '-Ddemo.flag=on --add-opens java.base/java.lang=ALL-UNNAMED "-Dname=a b"',
args: "--spring.profiles.active=dev",
});
});
it("lets java-debug shorten a classpath too long for the OS, as Run does", async () => {
for (const how of ["jarmanifest", "argfile"]) {
shortenCommandLine = how;
const l = await javaLaunchConfig(app, "/p");
expect(JSON.parse(l.extraJson!).shortenCommandLine).toBe(how);
}
});
it("routes a Kotlin main to the Kotlin adapter and preserves JVM launch arguments", async () => {
const config = { ...app, debugAdapter: "kotlin-debug-adapter" };
expect(isJavaDebuggable(config)).toBe(true);
const l = await javaLaunchConfig(config, "/p");
expect(l).toMatchObject({ adapter: "kotlin-debug-adapter", request: "launch", cwd: "/p/api", env: { A: "1" } });
expect(JSON.parse(l.extraJson!)).toMatchObject({
projectRoot: "/p/api",
mainClass: "a.Main",
classPaths: ["/p/target/classes", "/m2/a.jar"],
javaExec: "/jdk21/bin/java",
vmArguments: ["-Xmx1g"],
programArgs: ["--port", "8081 x"],
});
expect(invoked.some(([command]) => command === "javaDebugServer")).toBe(false);
});
it("routes launch and JDWP attach in a mixed module to Kotlin without starting jdtls' adapter", async () => {
kotlinFiles = true;
expect((await javaLaunchConfig(app, "/p")).adapter).toBe("kotlin-debug-adapter");
const l = await javaAttachConfig({ name: "mvn test", module: "api", hostName: "remote", port: 5005 }, "/p");
expect(l.adapter).toBe("kotlin-debug-adapter");
expect(JSON.parse(l.extraJson!)).toEqual({
projectRoot: "/p/api",
hostName: "remote",
port: 5005,
timeout: 30_000,
});
expect(invoked.some(([command]) => command === "javaDebugServer")).toBe(false);
});
it("attaches to a JDWP agent", async () => {
const l = await javaAttachConfig({ name: "mvn test", module: "", hostName: "127.0.0.1", port: 5005 }, "/p");
expect(l).toMatchObject({ request: "attach", cwd: "/p" });
expect(JSON.parse(l.extraJson!)).toMatchObject({ debugServer: 41234, hostName: "127.0.0.1", port: 5005 });
});
});