Files
openidea/src/features/debugger/__tests__/smartStep.test.ts
T
l0ng-ai be7e2ff710 fix(debugger): keep execution highlights synchronized (#681)
Inline debugger values looked up source identifiers in a plain object, so a name like toString on a nearby line resolved to an inherited Object.prototype member and crashed the editor's inline-values plugin. The execution-point subscription lived in that plugin, so after the crash the highlighted line stayed on the old position while the stack had moved on, and resume or stop did not clear it.

Locals are now kept in a prototype-free dictionary and looked up by own property, and the execution point is synced by its own plugin so an inline-value failure can't freeze it; hidden editors resync when remounted. Stepping, resume, run to cursor, reset frame and smart step clear the old execution point as soon as the request is sent, stale stack/locals responses from a previous stop are ignored, and another thread's continued event no longer clears the selected thread's execution point.

Fixes #668
2026-11-01 19:04:04 +08:00

156 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { _addEditor, _removeEditor, type TextEditorHandle } from "@/editor/api";
import { adoptSession, useDebug, useExecutionPoint } from "../state";
import { useOverlays } from "../ui/popoverStore";
import {
calleeName,
installSmartStepPicking,
locateStepTargets,
smartStepTargets,
startInlineSmartStep,
targetsOnLine,
} from "../editor/smartStep";
const ipc = vi.hoisted(() => ({ debugStepInTarget: vi.fn() }));
vi.mock("@/platform/ipc", async (orig) => {
const real = await orig<typeof import("@/platform/ipc")>();
return {
...real,
call: (p: Promise<unknown>) => p,
commands: {
...real.commands,
debugStepInTarget: (...a: unknown[]) => (ipc.debugStepInTarget(...a), Promise.resolve(null)),
},
};
});
const target = (id: number, label: string, column: number | null = null, endColumn: number | null = null) => ({
id,
label,
line: null,
column,
endColumn,
});
describe("locating Smart Step Into targets", () => {
it("names the callee of a label", () => {
expect(calleeName("parse(str(i))")).toBe("parse");
expect(calleeName("java.lang.String.valueOf(int)")).toBe("valueOf");
expect(calleeName("<lambda>")).toBeNull();
});
it("places targets by name, in line order, or by adapter columns", () => {
const line = " x = parse(str(i)) + str (j)";
const r = locateStepTargets(line, 3, [target(1, "str(i)"), target(2, "parse(str(i))"), target(3, "str(j)")]);
expect(r).toEqual([
{ id: 2, from: 8, to: 13 },
{ id: 1, from: 14, to: 17 },
{ id: 3, from: 24, to: 27 },
]);
expect(locateStepTargets(line, 3, [target(7, "whatever", 8, 13), target(8, "x", 14)])).toEqual([
{ id: 7, from: 8, to: 13 },
{ id: 8, from: 14, to: 17 },
]);
expect(locateStepTargets(line, 3, [target(1, "missing()")])).toBeNull();
});
it("rejects identifier suffixes, preserving UTF-16 offsets and nested calls", () => {
const line = "𐐀foo() αfoo() $foo() _foo() foo(foo())";
const from = line.indexOf("foo(foo");
expect(locateStepTargets(line, 0, [target(1, "foo()"), target(2, "foo()")])).toEqual([
{ id: 1, from, to: from + 3 },
{ id: 2, from: from + 4, to: from + 7 },
]);
expect(targetsOnLine("𐐀foo() bar()", 0, [target(1, "foo()"), target(2, "bar()")]).map((t) => t.id)).toEqual([2]);
});
it("keeps only the paused line's calls when the adapter lists the whole function", () => {
const line = " total = add(total, square(i))";
const all = [target(0, "square(i)"), target(1, "add(total, square(i))"), target(2, 'print("total", total)')];
expect(targetsOnLine(line, 11, all).map((t) => t.id)).toEqual([0, 1]);
expect(
targetsOnLine(line, 11, [
{ ...target(5, "print()"), line: 11 },
{ ...target(6, "add()"), line: 12 },
]).map((t) => t.id),
).toEqual([5]);
expect(targetsOnLine("pass", 0, all)).toBe(all);
});
});
describe("picking in the editor", () => {
const views: EditorView[] = [];
const cleanup: Array<() => void> = [];
afterEach(() => {
cleanup.splice(0).forEach((d) => d());
views.splice(0).forEach((v) => v.destroy());
useOverlays.setState({ smartStep: null });
useExecutionPoint.setState({ path: null, line: 0, top: true, values: {} });
ipc.debugStepInTarget.mockReset();
});
function setup() {
useDebug.setState({ sessions: {}, active: null });
adoptSession(3, { status: "paused", selectedThread: 1, stoppedThread: 1 });
const ctx = { doc: 1, path: "/p/app.py", language: "python", uiMode: null, readOnly: false };
const view = new EditorView({
state: EditorState.create({ doc: "def f():\n x = parse(str(i))\n", extensions: [smartStepTargets(ctx)] }),
parent: document.body,
});
views.push(view);
const handle: TextEditorHandle = { view, ctx };
_addEditor(handle);
cleanup.push(() => _removeEditor(handle), installSmartStepPicking());
useExecutionPoint.setState({ path: "/p/app.py", line: 1, top: true });
return view;
}
const flush = () => new Promise((r) => setTimeout(r, 0));
const key = (k: string, init: KeyboardEventInit = {}) =>
window.dispatchEvent(new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true, ...init }));
const marked = (view: EditorView) =>
[...view.dom.querySelectorAll("[data-step-target]")].map((e) => [e.textContent, e.getAttribute("aria-current")]);
it("highlights the calls, moves with arrows and steps in with Enter", async () => {
const view = setup();
expect(startInlineSmartStep(3, 1, [target(1, "str(i)"), target(2, "parse(str(i))")])).toBe(true);
await flush();
expect(marked(view)).toEqual([
["parse", null],
["str", "true"],
]);
key("ArrowLeft");
await flush();
expect(marked(view)[0]).toEqual(["parse", "true"]);
key("Enter");
expect(ipc.debugStepInTarget).toHaveBeenCalledWith(3, 1, 2);
expect(useOverlays.getState().smartStep).toBeNull();
await flush();
expect(marked(view)).toEqual([]);
});
it("Escape cancels; a click on a call steps into it", async () => {
const view = setup();
startInlineSmartStep(3, 1, [target(1, "str(i)"), target(2, "parse(str(i))")]);
key("Escape");
expect(useOverlays.getState().smartStep).toBeNull();
expect(ipc.debugStepInTarget).not.toHaveBeenCalled();
startInlineSmartStep(3, 1, [target(1, "str(i)"), target(2, "parse(str(i))")]);
await flush();
const parse = view.dom.querySelector("[data-step-target='2']")!;
parse.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
expect(ipc.debugStepInTarget).toHaveBeenCalledWith(3, 1, 2);
});
it("uses the dialog when the line is not open or the calls cannot be placed", () => {
setup();
expect(startInlineSmartStep(3, 1, [target(1, "a()"), target(2, "b()")])).toBe(false);
useExecutionPoint.setState({ path: "/p/other.py" });
expect(startInlineSmartStep(3, 1, [target(1, "str(i)"), target(2, "parse(str(i))")])).toBe(false);
expect(useOverlays.getState().smartStep).toBeNull();
});
});