fix(debug): stopped native fallback for missing delve
- Kept missing language-specific adapters from falling through to native debuggers. - Resolved nested launch roots before session-local binaries and PATH, including explicit adapters and go.work workspaces. - Added actionable install/configuration errors and deterministic regression coverage. Fixes #5037
This commit is contained in:
@@ -2,7 +2,14 @@ import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { getAdapterConfigs, resolveAdapter, selectLaunchAdapter } from "../../src/dap/config";
|
||||
import * as piUtils from "@oh-my-pi/pi-utils";
|
||||
import {
|
||||
getAdapterConfigs,
|
||||
type LaunchAdapterSelection,
|
||||
resolveAdapter,
|
||||
selectLaunchAdapter,
|
||||
} from "../../src/dap/config";
|
||||
import type { DapResolvedAdapter } from "../../src/dap/types";
|
||||
import { injectPluginDirRoots } from "../../src/discovery/helpers";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@@ -15,6 +22,45 @@ async function makeTempDir(prefix: string): Promise<string> {
|
||||
return cwd;
|
||||
}
|
||||
|
||||
interface NestedGoProgram {
|
||||
moduleRoot: string;
|
||||
program: string;
|
||||
}
|
||||
|
||||
async function writeExecutable(filePath: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n");
|
||||
await fs.chmod(filePath, 0o755);
|
||||
}
|
||||
|
||||
async function writeDlvOverride(cwd: string, command: string): Promise<void> {
|
||||
await fs.writeFile(path.join(cwd, "dap.json"), JSON.stringify({ adapters: { dlv: { command } } }));
|
||||
}
|
||||
|
||||
async function setupMissingDlvProject(cwd: string): Promise<string> {
|
||||
const missingCommand = path.join(cwd, "tools", "missing-dlv");
|
||||
await fs.writeFile(path.join(cwd, "go.mod"), "module example.com/app\n\ngo 1.22\n");
|
||||
await writeExecutable(path.join(cwd, "bin", "gdb"));
|
||||
await writeDlvOverride(cwd, missingCommand);
|
||||
return missingCommand;
|
||||
}
|
||||
|
||||
async function setupNestedGoProgram(cwd: string): Promise<NestedGoProgram> {
|
||||
const moduleRoot = path.join(cwd, "services", "api");
|
||||
const program = path.join(moduleRoot, "main.go");
|
||||
await fs.mkdir(moduleRoot, { recursive: true });
|
||||
await fs.writeFile(path.join(moduleRoot, "go.mod"), "module example.com/api\n\ngo 1.22\n");
|
||||
await fs.writeFile(program, "package main\n\nfunc main() {}\n");
|
||||
return { moduleRoot, program };
|
||||
}
|
||||
|
||||
function requireSelectedAdapter(selection: LaunchAdapterSelection): DapResolvedAdapter {
|
||||
if (selection.kind !== "adapter") {
|
||||
throw new Error(`Expected an available adapter, received '${selection.kind}'`);
|
||||
}
|
||||
return selection.adapter;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
if (ORIGINAL_OMP_PLUGIN_DIR === undefined) {
|
||||
@@ -63,8 +109,8 @@ describe("DAP adapter configuration", () => {
|
||||
expect(adapter?.launchDefaults).toEqual({ request: "launch", mainClass: "" });
|
||||
expect(adapter?.attachDefaults).toEqual({ request: "attach", host: "127.0.0.1" });
|
||||
|
||||
const selected = selectLaunchAdapter(path.join("src", "Main.java"), cwd);
|
||||
expect(selected?.name).toBe("custom-jvm");
|
||||
const selected = requireSelectedAdapter(selectLaunchAdapter(path.join("src", "Main.java"), cwd));
|
||||
expect(selected.name).toBe("custom-jvm");
|
||||
});
|
||||
|
||||
it("merges partial user overrides over built-in adapters", async () => {
|
||||
@@ -116,9 +162,9 @@ describe("DAP adapter configuration", () => {
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const selected = selectLaunchAdapter("Main.kt", cwd);
|
||||
expect(selected?.name).toBe("yaml-kotlin");
|
||||
expect(selected?.launchDefaults).toEqual({ request: "launch", projectRoot: "." });
|
||||
const selected = requireSelectedAdapter(selectLaunchAdapter("Main.kt", cwd));
|
||||
expect(selected.name).toBe("yaml-kotlin");
|
||||
expect(selected.launchDefaults).toEqual({ request: "launch", projectRoot: "." });
|
||||
});
|
||||
|
||||
it("resolves relative adapter commands from the debug cwd", async () => {
|
||||
@@ -195,4 +241,100 @@ describe("DAP adapter configuration", () => {
|
||||
expect(config["missing-command"]).toBeUndefined();
|
||||
expect(config.valid?.command).toBe("bun");
|
||||
});
|
||||
|
||||
it("reports missing dlv for Go source instead of falling back to a native debugger", async () => {
|
||||
const cwd = await makeTempDir("omp-dap-go-source-missing-");
|
||||
const missingCommand = await setupMissingDlvProject(cwd);
|
||||
const program = path.join(cwd, "main.go");
|
||||
await fs.writeFile(program, "package main\n\nfunc main() {}\n");
|
||||
|
||||
const selection = selectLaunchAdapter(program, cwd);
|
||||
|
||||
expect(selection).toEqual({ kind: "unavailable", adapterName: "dlv", command: missingCommand });
|
||||
});
|
||||
|
||||
it("reports missing dlv for Go package directories instead of selecting a native debugger", async () => {
|
||||
const cwd = await makeTempDir("omp-dap-go-directory-missing-");
|
||||
const missingCommand = await setupMissingDlvProject(cwd);
|
||||
const program = path.join(cwd, "cmd", "server");
|
||||
await fs.mkdir(program, { recursive: true });
|
||||
|
||||
const selection = selectLaunchAdapter(program, cwd, undefined, "directory");
|
||||
|
||||
expect(selection).toEqual({ kind: "unavailable", adapterName: "dlv", command: missingCommand });
|
||||
});
|
||||
|
||||
it("prefers a nested module adapter over cwd and PATH for inferred launches", async () => {
|
||||
const cwd = await makeTempDir("omp-dap-go-nested-local-");
|
||||
const { moduleRoot, program } = await setupNestedGoProgram(cwd);
|
||||
const nestedDlv = path.join(moduleRoot, "bin", "dlv");
|
||||
await writeExecutable(nestedDlv);
|
||||
await fs.writeFile(path.join(cwd, "go.mod"), "module example.com/repo\n\ngo 1.22\n");
|
||||
await writeExecutable(path.join(cwd, "bin", "dlv"));
|
||||
const whichSpy = vi.spyOn(piUtils, "$which").mockReturnValue(path.join(cwd, "global", "dlv"));
|
||||
|
||||
const selected = requireSelectedAdapter(selectLaunchAdapter(program, cwd));
|
||||
|
||||
expect(selected.resolvedCommand).toBe(nestedDlv);
|
||||
expect(whichSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses a nested module adapter when dlv is requested explicitly", async () => {
|
||||
const cwd = await makeTempDir("omp-dap-go-nested-explicit-");
|
||||
const { moduleRoot, program } = await setupNestedGoProgram(cwd);
|
||||
const nestedDlv = path.join(moduleRoot, "bin", "dlv");
|
||||
await writeExecutable(nestedDlv);
|
||||
const whichSpy = vi.spyOn(piUtils, "$which").mockReturnValue(path.join(cwd, "global", "dlv"));
|
||||
|
||||
const selected = requireSelectedAdapter(selectLaunchAdapter(program, cwd, "dlv"));
|
||||
|
||||
expect(selected.resolvedCommand).toBe(nestedDlv);
|
||||
expect(whichSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefers the session cwd adapter over PATH after a nested-root miss", async () => {
|
||||
const cwd = await makeTempDir("omp-dap-go-nested-cwd-");
|
||||
const { program } = await setupNestedGoProgram(cwd);
|
||||
const cwdDlv = path.join(cwd, "bin", "dlv");
|
||||
await fs.writeFile(path.join(cwd, "go.mod"), "module example.com/repo\n\ngo 1.22\n");
|
||||
await writeExecutable(cwdDlv);
|
||||
const whichSpy = vi.spyOn(piUtils, "$which").mockReturnValue(path.join(cwd, "global", "dlv"));
|
||||
|
||||
const selected = requireSelectedAdapter(selectLaunchAdapter(program, cwd));
|
||||
|
||||
expect(selected.resolvedCommand).toBe(cwdDlv);
|
||||
expect(whichSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves a local dlv for Go workspaces rooted by go.work", async () => {
|
||||
const cwd = await makeTempDir("omp-dap-go-work-");
|
||||
const program = path.join(cwd, "cmd", "worker");
|
||||
const localDlv = path.join(cwd, "bin", "dlv");
|
||||
await fs.writeFile(path.join(cwd, "go.work"), "go 1.22\n\nuse ./cmd/worker\n");
|
||||
await fs.mkdir(program, { recursive: true });
|
||||
await writeExecutable(localDlv);
|
||||
|
||||
const selected = requireSelectedAdapter(selectLaunchAdapter(program, cwd, undefined, "directory"));
|
||||
|
||||
expect(selected.resolvedCommand).toBe(localDlv);
|
||||
});
|
||||
|
||||
it("re-resolves an adapter installed after an earlier miss", async () => {
|
||||
const cwd = await makeTempDir("omp-dap-go-fresh-");
|
||||
const program = path.join(cwd, "main.go");
|
||||
const command = path.join(cwd, "tools", process.platform === "win32" ? "dlv.cmd" : "dlv");
|
||||
await fs.writeFile(path.join(cwd, "go.mod"), "module example.com/cache\n\ngo 1.22\n");
|
||||
await fs.writeFile(program, "package main\n\nfunc main() {}\n");
|
||||
await writeDlvOverride(cwd, command);
|
||||
|
||||
expect(selectLaunchAdapter(program, cwd)).toEqual({
|
||||
kind: "unavailable",
|
||||
adapterName: "dlv",
|
||||
command,
|
||||
});
|
||||
|
||||
await writeExecutable(command);
|
||||
const selected = requireSelectedAdapter(selectLaunchAdapter(program, cwd));
|
||||
expect(selected.resolvedCommand).toBe(command);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -489,7 +489,10 @@ describe("DAP launch failure handling", () => {
|
||||
|
||||
describe("DebugTool launch validation", () => {
|
||||
it("rejects directory programs when the selected adapter cannot debug a directory", async () => {
|
||||
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue(TEST_ADAPTER);
|
||||
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue({
|
||||
kind: "adapter",
|
||||
adapter: TEST_ADAPTER,
|
||||
});
|
||||
try {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-program-"));
|
||||
try {
|
||||
@@ -523,7 +526,10 @@ describe("DebugTool launch validation", () => {
|
||||
launchDefaults: { request: "launch", mode: "debug", stopOnEntry: true },
|
||||
acceptsDirectoryProgram: true,
|
||||
};
|
||||
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue(dlvAdapter);
|
||||
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue({
|
||||
kind: "adapter",
|
||||
adapter: dlvAdapter,
|
||||
});
|
||||
const sessionLaunchSpy = spyOn(dapModule.dapSessionManager, "launch").mockImplementation(async opts => {
|
||||
throw Object.assign(new Error("captured launch"), { capturedOptions: opts });
|
||||
});
|
||||
@@ -603,7 +609,10 @@ describe("DebugTool launch validation", () => {
|
||||
launchDefaults: { request: "launch", mode: "debug", stopOnEntry: true },
|
||||
acceptsDirectoryProgram: true,
|
||||
};
|
||||
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue(dlvAdapter);
|
||||
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue({
|
||||
kind: "adapter",
|
||||
adapter: dlvAdapter,
|
||||
});
|
||||
const sessionLaunchSpy = spyOn(dapModule.dapSessionManager, "launch").mockImplementation(async opts => {
|
||||
throw Object.assign(new Error("captured launch"), { capturedOptions: opts });
|
||||
});
|
||||
@@ -635,7 +644,11 @@ describe("DebugTool launch validation", () => {
|
||||
});
|
||||
|
||||
it("throws targeted 'python not found in PATH' when adapter:'debugpy' is unresolvable for launch", async () => {
|
||||
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue(null);
|
||||
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue({
|
||||
kind: "unavailable",
|
||||
adapterName: "debugpy",
|
||||
command: "python",
|
||||
});
|
||||
try {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-debugpy-"));
|
||||
try {
|
||||
@@ -685,8 +698,93 @@ describe("DebugTool launch validation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the Delve install command when the canonical dlv adapter is unavailable", async () => {
|
||||
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue({
|
||||
kind: "unavailable",
|
||||
adapterName: "dlv",
|
||||
command: "dlv",
|
||||
});
|
||||
try {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-dlv-hint-"));
|
||||
try {
|
||||
await fs.writeFile(path.join(cwd, "main.go"), "package main\n\nfunc main() {}\n");
|
||||
const session: ToolSession = {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
getSessionFile: () => null,
|
||||
getSessionSpawns: () => "*",
|
||||
settings: Settings.isolated({ "debug.enabled": true }),
|
||||
};
|
||||
const tool = new DebugTool(session);
|
||||
|
||||
await expect(tool.execute("call", { action: "launch", program: "main.go" })).rejects.toThrow(
|
||||
/go install github\.com\/go-delve\/delve\/cmd\/dlv@latest/,
|
||||
);
|
||||
} finally {
|
||||
await removeWithRetries(cwd);
|
||||
}
|
||||
} finally {
|
||||
launchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("points to DAP configuration when a custom adapter command is unavailable", async () => {
|
||||
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue({
|
||||
kind: "unavailable",
|
||||
adapterName: "dlv",
|
||||
command: "./bin/missing-dlv",
|
||||
});
|
||||
try {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-dlv-config-"));
|
||||
try {
|
||||
await fs.writeFile(path.join(cwd, "main.go"), "package main\n\nfunc main() {}\n");
|
||||
const session: ToolSession = {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
getSessionFile: () => null,
|
||||
getSessionSpawns: () => "*",
|
||||
settings: Settings.isolated({ "debug.enabled": true }),
|
||||
};
|
||||
const tool = new DebugTool(session);
|
||||
|
||||
await expect(tool.execute("call", { action: "launch", program: "main.go" })).rejects.toThrow(
|
||||
/configured command '\.\/bin\/missing-dlv' did not resolve.*DAP adapter config/,
|
||||
);
|
||||
} finally {
|
||||
await removeWithRetries(cwd);
|
||||
}
|
||||
} finally {
|
||||
launchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the rdbg install command for explicit Ruby attach", async () => {
|
||||
const attachSpy = spyOn(dapModule, "selectAttachAdapter").mockReturnValue(null);
|
||||
try {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-rdbg-attach-"));
|
||||
try {
|
||||
const session: ToolSession = {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
getSessionFile: () => null,
|
||||
getSessionSpawns: () => "*",
|
||||
settings: Settings.isolated({ "debug.enabled": true }),
|
||||
};
|
||||
const tool = new DebugTool(session);
|
||||
|
||||
await expect(tool.execute("call", { action: "attach", pid: 1234, adapter: "rdbg" })).rejects.toThrow(
|
||||
/gem install debug/,
|
||||
);
|
||||
} finally {
|
||||
await removeWithRetries(cwd);
|
||||
}
|
||||
} finally {
|
||||
attachSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the generic 'No debugger adapter' error when adapter is unspecified", async () => {
|
||||
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue(null);
|
||||
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue({ kind: "none" });
|
||||
try {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-noadapter-"));
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user