diff --git a/.github/workflows/language-smoke.yml b/.github/workflows/language-smoke.yml index f8d14c61f..5ebaa200c 100644 --- a/.github/workflows/language-smoke.yml +++ b/.github/workflows/language-smoke.yml @@ -3,7 +3,9 @@ name: Language smoke # with a quick fix, an intention, formatting, rename) per language, against the language # server the app starts by default: crates/ide-host/tests/language_smoke.rs. # Then running and testing each fixture through the run store and openidea-server -# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts. +# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts; and debugging its +# entry point with the debugger the IDE installs (breakpoint, frames, variables, evaluate, +# Step Over, Resume): smoke/debug.test.ts. on: push: branches: [main] @@ -17,6 +19,7 @@ on: - "Cargo.toml" - "Cargo.lock" - "src/features/run/**" + - "src/features/debugger/**" - "src/platform/**" - "smoke/**" - "package.json" @@ -108,3 +111,6 @@ jobs: - run: pnpm install --frozen-lockfile - run: cargo build --locked -p ide-host --bin openidea-server - run: pnpm smoke smoke/run.test.ts -t "^${{ matrix.language }}$" + - run: pnpm smoke smoke/debug.test.ts -t "^${{ matrix.language }}$" + # The IDE installs the debugger from GitHub releases: authenticated, against rate limits. + env: { GITHUB_TOKEN: "${{ github.token }}" } diff --git a/AGENTS.md b/AGENTS.md index adcb49b51..1b667bd8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,9 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them): on `fixtures/` with that language's real server installed (CI: `Language smoke`, pinned versions). - `pnpm smoke smoke/run.test.ts -t ` — run and test that language's fixture through the run store and a real `openidea-server` (build it first: `cargo build -p ide-host --bin openidea-server`). +- `pnpm smoke smoke/debug.test.ts -t ` — debug that fixture's entry point the same way, with + the debugger the IDE installs (on macOS, CodeLLDB and Delve may need Developer Mode: + `sudo DevToolsSecurity -enable`). - `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`, `pnpm gen:bindings`. - `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion diff --git a/fixtures/README.md b/fixtures/README.md index 80a5a5eef..a4353ca3f 100644 --- a/fixtures/README.md +++ b/fixtures/README.md @@ -20,9 +20,10 @@ The fixtures are standalone: `rust-app` declares its own `[workspace]` and The language smoke test (`crates/ide-host/tests/language_smoke.rs`) runs completion, navigation, diagnostics, quick fixes, formatting and rename on these projects with real -language servers, and `smoke/run.test.ts` runs each project's run and test configurations -(the intentionally failing test included, then Rerun Failed Tests): when you change a -fixture, run both for that language. +language servers, `smoke/run.test.ts` runs each project's run and test configurations +(the intentionally failing test included, then Rerun Failed Tests), and `smoke/debug.test.ts` +debugs its entry point from the "Set a breakpoint here" line: when you change a fixture, run +them for that language. ## Setup diff --git a/smoke/debug.test.ts b/smoke/debug.test.ts new file mode 100644 index 000000000..1e336b0e6 --- /dev/null +++ b/smoke/debug.test.ts @@ -0,0 +1,251 @@ +/** + * 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 = { + 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 { + 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(); + 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(what: string, value: () => T | null | undefined | false, timeoutMs = 60_000): Promise { + 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((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)); +} diff --git a/smoke/desktop.ts b/smoke/desktop.ts index 768fe8e43..c8919e696 100644 --- a/smoke/desktop.ts +++ b/smoke/desktop.ts @@ -3,23 +3,25 @@ * (callbacks, `Channel`s, the event plugin) in-process, and every app command sent to an * `openidea-server` through the route remote development uses (`src/platform/tauriCore.ts`). */ +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; import { setInvokeRoute } from "@/platform/tauriCore"; import { encodeArgs } from "@/platform/remote"; import { Server } from "./server"; type Callback = (message: unknown) => void; -// One runtime per page, as in the app: the frontend registers its event listeners once -// (`src/platform/ipc.ts`), so they outlive the servers connected one after another. +// One runtime per page, there from the start as in the app: the frontend registers its event +// listener once (`src/platform/ipc.ts`), possibly before a server is connected, and keeps it +// across the servers connected one after another. const callbacks = new Map(); const listeners: { event: string; handler: number }[] = []; const channels = new Map void }>(); let nextCallback = 1; -let installed = false; +/** `BaseDirectory` values (`@tauri-apps/api/path`) the frontend resolves. */ +const DIRECTORIES: Record = { 12: tmpdir(), 21: homedir() }; -function install() { - if (installed) return; - installed = true; +{ const internals = { metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main", windowLabel: "main" } }, transformCallback(cb: Callback) { @@ -38,6 +40,9 @@ function install() { return listeners.length; } if (cmd === "plugin:window|get_all_windows") return []; + // This machine's directories (`@tauri-apps/api/path`), as on the desktop. + if (cmd === "plugin:path|resolve_directory") return DIRECTORIES[args.directory as number] ?? null; + if (cmd === "plugin:path|join") return join(...(args.paths as string[])); if (cmd.startsWith("plugin:")) return null; throw { kind: "unavailable", message: `${cmd} has no route`, detail: null }; }, @@ -50,7 +55,6 @@ function install() { /** Connects this page's frontend to `server`; returns a function that disconnects it. */ export function connectDesktop(server: Server): () => void { - install(); channels.clear(); server.onChannel = (id, data) => channels.get(id)?.onmessage(data); server.onEvent = (target, event) => { diff --git a/smoke/server.ts b/smoke/server.ts index 97250b00e..241e8130a 100644 --- a/smoke/server.ts +++ b/smoke/server.ts @@ -3,6 +3,7 @@ * JSON-RPC with `Content-Length` framing over the server's stdin / stdout. */ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import { appendFileSync } from "node:fs"; import { dirname, join } from "node:path"; const PROTOCOL_VERSION = 2; @@ -35,6 +36,8 @@ export class Server { private constructor(private readonly child: ChildProcessWithoutNullStreams) { child.stdout.on("data", (chunk: Buffer) => this.receive(chunk)); child.stderr.on("data", (chunk: Buffer) => { + // The whole log, when asked for (OPENIDEA_SMOKE_LOG=; OPENIDEA_LOG sets its level). + if (process.env.OPENIDEA_SMOKE_LOG) appendFileSync(process.env.OPENIDEA_SMOKE_LOG, chunk); this.stderr.push(chunk.toString()); if (this.stderr.length > 200) this.stderr.shift(); });