mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-14 16:07:52 +00:00
kotlin-debug-adapter (fwcd) is unmaintained: its evaluate only resolves plain local names and it reports breakpoints verified without checking. Kotlin debugging now uses the debugger inside JetBrains kotlin-lsp once its project import has finished, attached to the JVM the IDE starts through the existing JDWP link. kotlin-debug-adapter stays the fallback, with a notice saying why (not installed, still importing, import failed, cannot start). kotlin-lsp's DAP quirks (step stop reasons, thread ids from earlier stops, frame names, file: URIs) are handled in one place, and its invalidated events reload the Variables pane and watches. Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com>
520 lines
23 KiB
TypeScript
520 lines
23 KiB
TypeScript
/**
|
|
* Debugging each language's `fixtures/` project the way the editor does: a breakpoint on the
|
|
* entry point's "Set a breakpoint here" line, Debug from the file's context (or a saved
|
|
* configuration, when the debugger is an explicit choice), then the paused frame, its
|
|
* variables, evaluation, Step Over and Resume, with the debugger the IDE installs (GDB:
|
|
* the toolchain's own, 14+ for its DAP interpreter).
|
|
*/
|
|
import { execFileSync } from "node:child_process";
|
|
import { cpSync, existsSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
import { get } from "node:http";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
|
|
import { Channel } from "@tauri-apps/api/core";
|
|
import { commands, type HeapProgress, type InstallEvent } from "@/bindings";
|
|
import { executeAction } from "@/platform/actions";
|
|
import { call, nextRequestId, onIdeEvent } from "@/platform/ipc";
|
|
import { useBuildModel } from "@/platform/buildModel";
|
|
import { openProject, useProject } from "@/platform/project";
|
|
import { activate as activateBuildTools } from "@/features/build-tools";
|
|
import { useBuild } from "@/features/build-tools/store";
|
|
import { activate as activateDebugger } from "@/features/debugger";
|
|
import { addBreakpoint } from "@/features/debugger/breakpoints/store";
|
|
import { activeSession, evaluate, rootOf, sessionSupports, useDebug } from "@/features/debugger/state";
|
|
import { activate as activateRun } from "@/features/run";
|
|
import { resetRuns, useRun } from "@/features/run/store";
|
|
import { activateWorkbench } from "@/workbench/activate";
|
|
import { useNotifications } from "@/workbench/notificationStore";
|
|
import { connectDesktop } from "./desktop";
|
|
import { repoRoot, Server } from "./server";
|
|
|
|
interface Case {
|
|
fixture: string;
|
|
/** Runs in the project copy first (installs its dependencies). */
|
|
setup?: (root: string) => void;
|
|
/** The IDE's tools that debug it, installed unless found. */
|
|
tools: string[];
|
|
/** The adapter of the session Debug starts. */
|
|
adapter: string;
|
|
/** A system tool that ships with the toolchain (the IDE does not install it): `adapter` is run to check it. */
|
|
system?: boolean;
|
|
/** A saved configuration to debug (the debugger is its explicit choice), instead of the file's context. */
|
|
configId?: string;
|
|
/** Global settings (key: value) written before the project opens. */
|
|
settings?: Record<string, unknown>;
|
|
/** The title of a notification Debug shows. */
|
|
notice?: string;
|
|
/** Instances, fields and referrers may answer `timedOut` while the adapter is still busy after a stop. */
|
|
retryTimedOut?: boolean;
|
|
/** The entry point, debugged from its context. */
|
|
file: string;
|
|
/** A variable of the paused frame. */
|
|
variable: string;
|
|
/** Its value once the adapter has filled in a placeholder (`invalidated`), as the Variables pane shows it. */
|
|
filledValue?: RegExp;
|
|
/** An expression evaluated there (Evaluate Expression), and its value. */
|
|
evaluate: [string, RegExp];
|
|
/** A server: the breakpoint is in this request's handler, and the session is stopped at the end. */
|
|
request?: string;
|
|
/** JVM languages: the live instances of a class the paused program holds (Memory tab). */
|
|
memory?: { className: string; instances: number };
|
|
/** JVM languages: a class of the heap snapshot captured there and its instance count. */
|
|
heap?: { className: string; instances: number };
|
|
/** JVM languages: that class's instances, a field of the first one and its referrers (Show Instances). */
|
|
instances?: { className: string; count: number; field: string };
|
|
}
|
|
|
|
/** Debugging the Kotlin fixture, with either debugger. */
|
|
const kotlin = {
|
|
fixture: "kotlin-gradle",
|
|
file: "src/main/kotlin/com/example/library/App.kt",
|
|
variable: "library",
|
|
evaluate: ["library", /Library/] as [string, RegExp],
|
|
// Gradle forks the JVM and the debugger attaches to it: the attach-session path.
|
|
memory: { className: "com.example.library.Book", instances: 3 },
|
|
heap: { className: "com.example.library.Book", instances: 3 },
|
|
instances: { className: "com.example.library.Book", count: 3, field: "title" },
|
|
};
|
|
|
|
const CASES: Record<string, Case> = {
|
|
typescript: {
|
|
fixture: "ts-app",
|
|
setup: (root) => execFileSync("npm", ["install", "--no-audit", "--no-fund"], { cwd: root, stdio: "ignore" }),
|
|
tools: ["js-debug"],
|
|
adapter: "js-debug",
|
|
file: "src/index.ts",
|
|
variable: "cart",
|
|
evaluate: ["cart.items.length", /^2$/],
|
|
},
|
|
python: {
|
|
fixture: "python-app",
|
|
setup: (root) => execFileSync("python3", ["-m", "venv", ".venv"], { cwd: root, stdio: "ignore" }),
|
|
tools: ["debugpy"],
|
|
adapter: "debugpy",
|
|
file: "src/tasks/cli.py",
|
|
variable: "tasks",
|
|
evaluate: ["len(tasks.pending())", /^3$/],
|
|
},
|
|
rust: {
|
|
fixture: "rust-app",
|
|
tools: ["codelldb"],
|
|
adapter: "codelldb",
|
|
file: "src/main.rs",
|
|
variable: "inventory",
|
|
evaluate: ["inventory", /\S/],
|
|
},
|
|
go: {
|
|
fixture: "go-app",
|
|
tools: ["delve"],
|
|
adapter: "delve",
|
|
file: "cmd/server/main.go",
|
|
variable: "r",
|
|
evaluate: ["r.Method", /^"GET"$/],
|
|
request: "http://127.0.0.1:8080/notes?q=openidea",
|
|
},
|
|
java: {
|
|
fixture: "java-app",
|
|
tools: ["java-debug"],
|
|
adapter: "java-debug",
|
|
file: "src/main/java/com/example/orders/App.java",
|
|
variable: "book",
|
|
evaluate: ["book.size()", /^3$/],
|
|
memory: { className: "com.example.orders.OrderBook", instances: 1 },
|
|
heap: { className: "com.example.orders.OrderBook", instances: 1 },
|
|
instances: { className: "com.example.orders.OrderBook", count: 1, field: "orders" },
|
|
},
|
|
kotlin: {
|
|
...kotlin,
|
|
tools: ["kotlin-lsp"],
|
|
adapter: "kotlin-lsp",
|
|
// kotlin-lsp evaluates for itself after each stop, which the inspector's link waits out.
|
|
retryTimedOut: true,
|
|
// First "Collecting data…", then the `toString` kotlin-lsp invokes.
|
|
filledValue: /Library@/,
|
|
},
|
|
"kotlin-fwcd": {
|
|
...kotlin,
|
|
tools: ["kotlin-debug-adapter"],
|
|
adapter: "kotlin-debug-adapter",
|
|
settings: { languageServers: { "kotlin-lsp": { enabled: false } } },
|
|
notice: "Debugging Kotlin with kotlin-debug-adapter",
|
|
},
|
|
// GDB's own DAP interpreter (GDB 14+), the configuration's explicit debugger choice:
|
|
// the C/C++ default stays lldb-dap. GDB 15.0.50.20240403-0ubuntu1 says `15`.
|
|
c: {
|
|
fixture: "c-app",
|
|
setup: (root) => execFileSync("make", ["app-c"], { cwd: root, stdio: "ignore" }),
|
|
tools: [],
|
|
adapter: "gdb",
|
|
system: true,
|
|
configId: "user:app-c",
|
|
file: "src/main.c",
|
|
variable: "value",
|
|
evaluate: ["print value", / = [0-9]/],
|
|
},
|
|
cpp: {
|
|
fixture: "c-app",
|
|
setup: (root) => execFileSync("make", ["app-cpp"], { cwd: root, stdio: "ignore" }),
|
|
tools: [],
|
|
adapter: "gdb",
|
|
system: true,
|
|
configId: "user:app-cpp",
|
|
file: "src/main.cpp",
|
|
variable: "line",
|
|
evaluate: ["print line", / = /],
|
|
},
|
|
php: {
|
|
fixture: "php-app",
|
|
setup: (root) =>
|
|
execFileSync("composer", ["install", "--no-interaction", "--no-progress"], { cwd: root, stdio: "ignore" }),
|
|
tools: ["php-debug"],
|
|
adapter: "php-debug",
|
|
file: "bin/quote.php",
|
|
variable: "$shipment",
|
|
evaluate: ["count($shipment)", /^3$/],
|
|
},
|
|
};
|
|
|
|
let deactivate: (() => void) | null = null;
|
|
let server: Server | null = null;
|
|
let disconnect: (() => void) | null = null;
|
|
let base = "";
|
|
|
|
/** Backend events received, for failure messages. */
|
|
const events: string[] = [];
|
|
beforeAll(() => {
|
|
onIdeEvent("*", (e) =>
|
|
events.push(
|
|
e.type === "debug"
|
|
? `debug ${e.session} ${e.event.kind}`
|
|
: e.type === "task"
|
|
? `task ${e.title}${e.done ? " (done)" : ""}`
|
|
: e.type,
|
|
),
|
|
);
|
|
const disposers = [activateWorkbench(), activateBuildTools(), activateRun(), activateDebugger()];
|
|
deactivate = () => disposers.reverse().forEach((d) => d());
|
|
});
|
|
afterAll(() => deactivate?.());
|
|
|
|
afterEach(async () => {
|
|
resetRuns();
|
|
disconnect?.();
|
|
// `close` kills the server and what it started (debug adapters, language servers)
|
|
// and waits until they're gone, so the removal below can't race their writes.
|
|
await server?.close();
|
|
if (base) rmSync(base, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
});
|
|
|
|
async function open(c: Case): Promise<string> {
|
|
base = realpathSync(mkdtempSync(join(tmpdir(), "openidea-smoke-")));
|
|
const root = join(base, c.fixture);
|
|
cpSync(join(repoRoot, "fixtures", c.fixture), root, { recursive: true });
|
|
c.setup?.(root);
|
|
server = await Server.start(join(base, "home"));
|
|
disconnect = connectDesktop(server);
|
|
for (const [key, value] of Object.entries(c.settings ?? {}))
|
|
await call(commands.settingsSet("global", key, JSON.stringify(value)));
|
|
await openProject(root);
|
|
const trusted = await commands.projectTrustSet(root, true, false);
|
|
if (trusted.status !== "ok") throw new Error(trusted.error.message);
|
|
useProject.setState({ project: { ...useProject.getState().project!, trusted: true } });
|
|
// Run and Debug follow the build (a Gradle project's Application runs through Gradle).
|
|
await until(
|
|
"the build model",
|
|
() => {
|
|
const error = useBuild.getState().error;
|
|
if (error) throw new Error(`the build model did not load: ${error}`);
|
|
return useBuildModel.getState().root === root && useBuildModel.getState().model;
|
|
},
|
|
5 * 60_000,
|
|
);
|
|
return root;
|
|
}
|
|
|
|
async function install(tool: string) {
|
|
const tools = await call(commands.toolsList());
|
|
// CI puts its pinned kotlin-lsp on `PATH`: that one is tested, not the latest release.
|
|
const status = tools.find((t) => t.name === tool)?.status;
|
|
if (status === "installed" || status === "onPath") return;
|
|
const channel = new Channel<InstallEvent>();
|
|
const info = await call(commands.toolsInstall(tool, nextRequestId(), channel));
|
|
expect(info.status, `${tool} installs`).toBe("installed");
|
|
}
|
|
|
|
/** What the Debug and Run tool windows show, for failure messages. */
|
|
function shown(): string {
|
|
const { sessions, console } = useDebug.getState();
|
|
const debug = Object.values(sessions).map(
|
|
(s) =>
|
|
`debug ${s.id} ${s.name} ${s.status} ${s.stopReason ?? ""}\n` +
|
|
(console[s.id] ?? [])
|
|
.slice(-40)
|
|
.map((l) => ` ${l.text}`)
|
|
.join(""),
|
|
);
|
|
const runs = useRun.getState().sessions.map((s) => `run ${s.id} ${s.config.name} ${s.status} ${s.exitCode ?? ""}`);
|
|
const notes = useNotifications.getState().items.map((n) => `${n.level}: ${n.title} ${n.body ?? ""}`);
|
|
return [...debug, ...runs, ...notes, `events: ${events.slice(-60).join(", ")}`, server?.log() ?? ""].join("\n");
|
|
}
|
|
|
|
/** Waits for `value` to be truthy; fails with what the tool windows show. */
|
|
async function until<T>(what: string, value: () => T | null | undefined | false, timeoutMs = 60_000): Promise<T> {
|
|
const end = Date.now() + timeoutMs;
|
|
for (;;) {
|
|
const v = value();
|
|
if (v) return v;
|
|
if (Date.now() > end) throw new Error(`timed out waiting for ${what}\n${shown()}`);
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
}
|
|
}
|
|
|
|
/** Requests `url` once the debugged server accepts connections (the response waits for Resume). */
|
|
async function requestWhenListening(url: string) {
|
|
for (let i = 0; i < 600; i++) {
|
|
const connected = await new Promise<boolean>((resolve) => {
|
|
const req = get(url, (res) => {
|
|
res.resume();
|
|
resolve(true);
|
|
});
|
|
req.on("socket", (socket) => socket.on("connect", () => resolve(true)));
|
|
req.on("error", () => resolve(false));
|
|
});
|
|
if (connected) return;
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
}
|
|
}
|
|
|
|
const paused = () => {
|
|
const s = activeSession();
|
|
return s?.status === "paused" && s.frames.length > 0 ? s : null;
|
|
};
|
|
|
|
/** Paused, failing as soon as every session has ended instead. */
|
|
const pausedOrEnded = () => {
|
|
const sessions = Object.values(useDebug.getState().sessions);
|
|
if (sessions.length > 0 && sessions.every((s) => s.status === "terminated"))
|
|
throw new Error(`the debugger ended before the breakpoint\n${shown()}`);
|
|
return paused();
|
|
};
|
|
|
|
async function smoke(language: string) {
|
|
const c = CASES[language]!;
|
|
const root = await open(c);
|
|
// GDB ships with the toolchain (14+ for its DAP interpreter); the IDE does not install it.
|
|
if (c.system) execFileSync(c.adapter, ["--version"], { stdio: "pipe" });
|
|
for (const tool of c.tools) await install(tool);
|
|
|
|
const path = join(root, c.file);
|
|
const content = readFileSync(path, "utf8");
|
|
const marker = content.split("\n").findIndex((l) => l.includes("Set a breakpoint here"));
|
|
const line = marker + 1;
|
|
addBreakpoint(path, line);
|
|
|
|
const started = Date.now();
|
|
const begun = events.length;
|
|
if (c.configId) {
|
|
const config = (await call(commands.runConfigs())).find((cfg) => cfg.id === c.configId);
|
|
expect(config, c.configId).toBeTruthy();
|
|
await executeAction("debug.startConfig", config);
|
|
} else {
|
|
await executeAction("run.debugContext", { path, content });
|
|
}
|
|
if (c.request) void requestWhenListening(c.request);
|
|
const s = await until("the breakpoint", pausedOrEnded, 5 * 60_000);
|
|
const firstStop = Date.now() - started;
|
|
expect(s.stopReason, `${s.stopDescription}\n${shown()}`).toBe("breakpoint");
|
|
expect(rootOf(s).adapter, shown()).toBe(c.adapter);
|
|
if (c.notice) {
|
|
const titles = useNotifications.getState().items.map((n) => n.title);
|
|
expect(titles, shown()).toContain(c.notice);
|
|
}
|
|
const top = s.frames[0]!;
|
|
expect(top.path).toBe(path);
|
|
expect(top.line).toBe(line);
|
|
|
|
const scopes = await call(commands.debugScopes(s.id, top.id));
|
|
let locals = await call(commands.debugVariables(s.id, scopes[0]!.variablesReference));
|
|
expect(locals.map((v) => v.name)).toContain(c.variable);
|
|
let filled = "";
|
|
if (c.filledValue) {
|
|
const since = Date.now();
|
|
let epoch: number | null = null;
|
|
let last = locals.find((v) => v.name === c.variable)?.value;
|
|
while (!c.filledValue.test(last ?? "")) {
|
|
if (Date.now() - since > 10_000) throw new Error(`${c.variable} stayed ${JSON.stringify(last)}\n${shown()}`);
|
|
const now = useDebug.getState().variablesEpoch;
|
|
if (now === epoch) {
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
continue;
|
|
}
|
|
// What the Variables pane does on each `invalidated`: pull the frame's locals again.
|
|
epoch = now;
|
|
const again = await call(commands.debugScopes(s.id, top.id));
|
|
locals = await call(commands.debugVariables(s.id, again.find((sc) => !sc.expensive)!.variablesReference));
|
|
last = locals.find((v) => v.name === c.variable)?.value;
|
|
}
|
|
filled = ` (filled in after ${((Date.now() - since) / 1000).toFixed(1)} s)`;
|
|
}
|
|
|
|
// The value, not kotlin-lsp's "Collecting data…" placeholder.
|
|
const value = await evaluate(c.evaluate[0], "repl", s);
|
|
expect(value.value, c.evaluate[0]).toMatch(c.evaluate[1]);
|
|
if (c.adapter === "gdb") {
|
|
const expression = await evaluate(c.variable, "watch", s);
|
|
expect(expression.value).toBeTruthy();
|
|
const environment = await evaluate("environment_ok", "watch", s);
|
|
expect(environment.value).toBe("1");
|
|
}
|
|
|
|
const filters = (await call(commands.debugExceptionFilters())).find((f) => f.session === rootOf(s).id);
|
|
const filterIds = filters?.filters.map((f) => f.filter) ?? [];
|
|
// The JVM debuggers offer caught / uncaught exceptions; Delve offers none.
|
|
if (c.memory) expect(filterIds, `exception filters of ${c.adapter}`).not.toHaveLength(0);
|
|
const waits = events.slice(begun).filter((e) => e.startsWith("task Waiting for")).length;
|
|
console.info(
|
|
`${language}: ${c.adapter} stopped after ${(firstStop / 1000).toFixed(1)} s (${waits} wait task events); ` +
|
|
`${c.variable} = ${locals.find((v) => v.name === c.variable)?.value}${filled}; evaluated ${value.value}; ` +
|
|
`exception filters [${filterIds.join(", ")}]`,
|
|
);
|
|
|
|
if (c.memory) {
|
|
const memory = await call(commands.debugJvmMemoryClasses(s.id));
|
|
if (memory.status === "ready") {
|
|
const row = memory.snapshot.rows.find((r) => r.name === c.memory!.className);
|
|
expect(row?.instances, `${c.memory.className} in the histogram\n${shown()}`).toBe(c.memory.instances);
|
|
} else {
|
|
// Attaching to a suspended JVM older than 17 hangs, so the backend refuses it by design.
|
|
const { reason } = memory;
|
|
const refused = reason.kind === "unsafeWhileSuspended" && reason.javaMajor > 0 && reason.javaMajor < 17;
|
|
expect(refused, `memory unavailable: ${JSON.stringify(reason)}\n${shown()}`).toBe(true);
|
|
}
|
|
}
|
|
|
|
if (c.heap) {
|
|
const { className, instances } = c.heap;
|
|
const snapshots = join(base, "home", "data", "cache", "heap-snapshots");
|
|
const dirs = () =>
|
|
existsSync(snapshots)
|
|
? readdirSync(snapshots, { withFileTypes: true })
|
|
.filter((e) => e.isDirectory())
|
|
.map((e) => e.name)
|
|
: [];
|
|
const phases: string[] = [];
|
|
const progress = new Channel<HeapProgress>();
|
|
progress.onmessage = (p) => {
|
|
const phase = p.type === "dumping" ? "dumping" : p.phase;
|
|
if (phases.at(-1) !== phase) phases.push(phase);
|
|
};
|
|
const since = Date.now();
|
|
const captured = await call(commands.debugHeapCapture(s.id, nextRequestId(), progress));
|
|
const captureMs = Date.now() - since;
|
|
if (captured.status === "unavailable") {
|
|
// The same refusal as the class table's, for the same reason.
|
|
const { reason } = captured;
|
|
const refused =
|
|
reason.kind === "memory" &&
|
|
reason.reason.kind === "unsafeWhileSuspended" &&
|
|
reason.reason.javaMajor > 0 &&
|
|
reason.reason.javaMajor < 17;
|
|
expect(refused, `heap unavailable: ${JSON.stringify(reason)}\n${shown()}`).toBe(true);
|
|
} else {
|
|
if (captured.status !== "ready") throw new Error(`heap capture: ${JSON.stringify(captured)}\n${shown()}`);
|
|
const { snapshot } = captured;
|
|
const held = dirs();
|
|
expect(held, "snapshot directories while open").toHaveLength(1);
|
|
const dumpBytes = statSync(join(snapshots, held[0]!, "dump.hprof")).size;
|
|
const classes = await call(commands.debugHeapClasses(snapshot.id));
|
|
const row = classes.find((r) => r.name === className);
|
|
expect(row?.count, `${className} in the heap snapshot`).toBe(instances);
|
|
expect(row!.shallow, `${className} shallow size`).toBeGreaterThan(0);
|
|
expect(row!.retained, `${className} retained size`).toBeGreaterThanOrEqual(row!.shallow);
|
|
const page = await call(commands.debugHeapDominators(snapshot.id, null, row!.classIndex, 0, 1));
|
|
expect(page.rows, `${className} dominator rows`).toHaveLength(1);
|
|
const path = await call(commands.debugHeapPath(snapshot.id, page.rows[0]!.object));
|
|
const last = path?.steps.at(-1)?.via;
|
|
expect(last?.kind, `path to a GC root: ${JSON.stringify(path)}`).toBe("root");
|
|
await call(commands.debugHeapClose(snapshot.id));
|
|
expect(dirs(), "snapshot directories after close").toEqual([]);
|
|
const origin = snapshot.origin;
|
|
const java = origin.kind === "captured" ? `JDK ${origin.javaVersion}, dump ${origin.dumpMs} ms` : origin.kind;
|
|
console.info(
|
|
`${language}: heap snapshot in ${captureMs} ms (${java}, analysis ${snapshot.summary.analysisMs} ms, ` +
|
|
`${dumpBytes} bytes, ${snapshot.summary.objects} objects; ${phases.join(" → ")}); ` +
|
|
`${className} ${row!.count} / ${row!.shallow} B / retained ${row!.retained} B, ${page.total} reachable; ` +
|
|
`path ${path!.steps.length + path!.omitted} steps to ${last?.kind === "root" ? last.root : "?"}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
let timedOutRetries = 0;
|
|
if (c.instances) {
|
|
const { className, count, field } = c.instances;
|
|
// The inspector comes up beside the session (jdtls bundle, Kotlin JDWP link).
|
|
await until("the JVM inspector", () => {
|
|
const session = useDebug.getState().sessions[s.id] ?? null;
|
|
const backend = session?.jvmBackend;
|
|
if (backend && backend.kind !== "ready") throw new Error(`JVM inspector ${JSON.stringify(backend)}\n${shown()}`);
|
|
return sessionSupports("jvmInstances", session);
|
|
});
|
|
type Answer = { status: "ready" } | { status: "unavailable"; reason: { kind: string } };
|
|
const ready = async <T extends Answer>(what: string, ask: () => Promise<T>) => {
|
|
let r = await ask();
|
|
for (let retry = 0; c.retryTimedOut && retry < 5; retry++) {
|
|
if (r.status !== "unavailable" || r.reason.kind !== "timedOut") break;
|
|
timedOutRetries++;
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
r = await ask();
|
|
}
|
|
expect(r.status, `${what}: ${JSON.stringify(r)}\n${shown()}`).toBe("ready");
|
|
return r as Extract<T, { status: "ready" }>;
|
|
};
|
|
const listed = await ready("instances", () => call(commands.debugJvmMemoryInstances(s.id, className, 1000)));
|
|
expect(listed.instances, `${className} instances`).toHaveLength(count);
|
|
const first = listed.instances[0]!;
|
|
if (!("id" in first)) throw new Error(`${className} instance without an id: ${JSON.stringify(first)}`);
|
|
const object = { generation: listed.generation, id: first.id };
|
|
const fields = await ready("fields", () => call(commands.debugJvmMemoryFields(s.id, object)));
|
|
const held = fields.fields.find((f) => f.name === field)?.value;
|
|
if (!held || !("id" in held)) throw new Error(`${className}.${field}: ${JSON.stringify(fields.fields)}`);
|
|
// Only objects count as referrers, not stack frames: Java's `OrderBook` is held by a local alone.
|
|
await ready("referrers", () => call(commands.debugJvmMemoryReferrers(s.id, object, 50)));
|
|
const heldRef = { generation: listed.generation, id: held.id };
|
|
const referrers = await ready("referrers", () => call(commands.debugJvmMemoryReferrers(s.id, heldRef, 50)));
|
|
const back = referrers.referrers.find((r) => "id" in r.value && r.value.id === first.id);
|
|
expect(back?.via, `referrers of ${className}.${field}: ${JSON.stringify(referrers)}`).toContain(field);
|
|
}
|
|
|
|
const stepped = events.length;
|
|
// Python 3.12 reports a conditional expression's line twice: Step Over may stop on it
|
|
// (and its breakpoint) again.
|
|
for (let step = 0; step < 3 && paused()!.frames[0]!.line === line; step++) {
|
|
const stops = useDebug.getState().stopSeq;
|
|
await executeAction("debug.stepOver");
|
|
await until("Step Over", () => useDebug.getState().stopSeq > stops && paused());
|
|
}
|
|
const after = paused()!;
|
|
expect(after.stopReason, shown()).toBe("step");
|
|
expect(after.frames[0]!.line, shown()).toBeGreaterThan(line);
|
|
|
|
const invalidated = (from: number, to = events.length) =>
|
|
events.slice(from, to).filter((e) => e.endsWith(" invalidated")).length;
|
|
console.info(
|
|
`${language}: invalidated ${invalidated(begun, stepped)} at the breakpoint, ${invalidated(stepped)} after Step Over; ` +
|
|
`${timedOutRetries} timedOut retries`,
|
|
);
|
|
|
|
await executeAction("debug.resume");
|
|
if (c.request) {
|
|
await until("the request to be served", () => useDebug.getState().sessions[s.id]?.status === "running");
|
|
await executeAction("debug.stop");
|
|
}
|
|
// Every session ends (a JVM that listens again as its debugger detaches may be attached once more).
|
|
await until("the end", () => Object.values(useDebug.getState().sessions).every((d) => d.status === "terminated"));
|
|
}
|
|
|
|
for (const language of Object.keys(CASES)) {
|
|
test(language, () => smoke(language));
|
|
}
|