mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
Debugs each language's fixture the way the editor does, against openidea-server and the debugger the IDE installs: a breakpoint on the entry point's marker line, Debug from the file's context, then the paused frame, its variables, Evaluate Expression, Step Over and Resume. Runs per language in the Language smoke workflow. The build model is loaded first, as the Build Tools feature does, so a Gradle Application is debugged through Gradle; Step Over allows a repeated stop on a line (Python 3.12 reports a conditional expression's line twice).
252 lines
9.6 KiB
TypeScript
252 lines
9.6 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, then the paused
|
|
* frame, its variables, evaluation, Step Over and Resume, with the debugger the IDE installs.
|
|
*/
|
|
import { execFileSync } from "node:child_process";
|
|
import { cpSync, mkdtempSync, readFileSync, realpathSync, rmSync } 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 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, 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 tool that debugs it. */
|
|
debugger: string;
|
|
/** The entry point, debugged from its context. */
|
|
file: string;
|
|
/** A variable of the paused frame. */
|
|
variable: string;
|
|
/** 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;
|
|
}
|
|
|
|
const CASES: Record<string, Case> = {
|
|
typescript: {
|
|
fixture: "ts-app",
|
|
setup: (root) => execFileSync("npm", ["install", "--no-audit", "--no-fund"], { cwd: root, stdio: "ignore" }),
|
|
debugger: "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" }),
|
|
debugger: "debugpy",
|
|
file: "src/tasks/cli.py",
|
|
variable: "tasks",
|
|
evaluate: ["len(tasks.pending())", /^3$/],
|
|
},
|
|
rust: {
|
|
fixture: "rust-app",
|
|
debugger: "codelldb",
|
|
file: "src/main.rs",
|
|
variable: "inventory",
|
|
evaluate: ["inventory", /\S/],
|
|
},
|
|
go: {
|
|
fixture: "go-app",
|
|
debugger: "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",
|
|
debugger: "java-debug",
|
|
file: "src/main/java/com/example/orders/App.java",
|
|
variable: "book",
|
|
evaluate: ["book.size()", /^3$/],
|
|
},
|
|
kotlin: {
|
|
fixture: "kotlin-gradle",
|
|
debugger: "kotlin-debug-adapter",
|
|
file: "src/main/kotlin/com/example/library/App.kt",
|
|
variable: "library",
|
|
evaluate: ["library", /Library/],
|
|
},
|
|
};
|
|
|
|
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));
|
|
const disposers = [activateWorkbench(), activateBuildTools(), activateRun(), activateDebugger()];
|
|
deactivate = () => disposers.reverse().forEach((d) => d());
|
|
});
|
|
afterAll(() => deactivate?.());
|
|
|
|
afterEach(() => {
|
|
resetRuns();
|
|
disconnect?.();
|
|
server?.close();
|
|
if (base) rmSync(base, { recursive: true, force: true });
|
|
});
|
|
|
|
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);
|
|
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());
|
|
if (tools.find((t) => t.name === tool)?.status === "installed") 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);
|
|
await install(c.debugger);
|
|
|
|
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);
|
|
|
|
await executeAction("run.debugContext", { path, content });
|
|
if (c.request) void requestWhenListening(c.request);
|
|
const s = await until("the breakpoint", pausedOrEnded, 5 * 60_000);
|
|
expect(s.stopReason, `${s.stopDescription}\n${shown()}`).toBe("breakpoint");
|
|
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));
|
|
const locals = await call(commands.debugVariables(s.id, scopes[0]!.variablesReference));
|
|
expect(locals.map((v) => v.name)).toContain(c.variable);
|
|
|
|
const value = await evaluate(c.evaluate[0], "repl", s);
|
|
expect(value.value, c.evaluate[0]).toMatch(c.evaluate[1]);
|
|
|
|
// 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);
|
|
|
|
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));
|
|
}
|