mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
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
296 lines
13 KiB
TypeScript
296 lines
13 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { EditorState } from "@codemirror/state";
|
|
import { EditorView, GutterMarker, gutter, lineNumbers } from "@codemirror/view";
|
|
import { breakpointGutter, lookOf } from "../editor/breakpointGutter";
|
|
import { executionPoint } from "../editor/execution";
|
|
import { breakpointsOf, loadBreakpoints, toggleBreakpoint, updateBreakpoint } from "../breakpoints/store";
|
|
import { adoptSession, useExecutionPoint, useDebug, handleDebugEvent } from "../state";
|
|
import { getContext } from "@/platform/context";
|
|
import styles from "../editor/editor.module.css";
|
|
|
|
const ctx = { doc: 1, path: "/proj/a.py", language: "python", uiMode: null, readOnly: false };
|
|
const views: EditorView[] = [];
|
|
|
|
function makeView(doc: string) {
|
|
const view = new EditorView({
|
|
state: EditorState.create({ doc, extensions: [lineNumbers(), breakpointGutter(ctx), executionPoint(ctx)] }),
|
|
parent: document.body,
|
|
});
|
|
views.push(view);
|
|
return view;
|
|
}
|
|
|
|
const flush = () => new Promise((r) => setTimeout(r, 0));
|
|
const flushFrame = () => new Promise((r) => requestAnimationFrame(r));
|
|
|
|
function gutterClasses(view: EditorView): string[] {
|
|
return [...view.dom.querySelectorAll(".cm-lineNumbers .cm-gutterElement")].map((e) => e.className);
|
|
}
|
|
|
|
afterEach(() => {
|
|
views.splice(0).forEach((v) => v.destroy());
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("breakpoint gutter", () => {
|
|
it("renders markers as classes on line numbers and tracks edits", async () => {
|
|
localStorage.clear();
|
|
loadBreakpoints("/proj");
|
|
const view = makeView("a\nb\nc\nd\ne");
|
|
toggleBreakpoint(ctx.path, 2);
|
|
await flush();
|
|
expect(gutterClasses(view).filter((c) => c.includes(styles.bp!))).toHaveLength(1);
|
|
// The number text is still rendered.
|
|
expect(view.dom.querySelector(`.cm-lineNumbers .${styles.bp}`)?.textContent).toBe("3");
|
|
|
|
view.dispatch({ changes: { from: 0, insert: "new\n" } });
|
|
expect(breakpointsOf(ctx.path).map((b) => b.line)).toEqual([3]);
|
|
|
|
const bp = breakpointsOf(ctx.path)[0]!;
|
|
updateBreakpoint(ctx.path, bp.id, { enabled: false });
|
|
await flush();
|
|
expect(view.dom.querySelector(`.${styles.disabled}`)?.textContent).toBe("4");
|
|
});
|
|
});
|
|
|
|
describe("output class gutter", () => {
|
|
it("disables ordinary, temporary and logging breakpoints and explains why", () => {
|
|
loadBreakpoints("/proj");
|
|
const path = "file:///proj/target/classes/C.class";
|
|
const view = new EditorView({
|
|
state: EditorState.create({
|
|
doc: "class C {}",
|
|
extensions: [
|
|
lineNumbers(),
|
|
breakpointGutter({ ...ctx, path, language: "java", readOnly: true, virtual: true }),
|
|
],
|
|
}),
|
|
parent: document.body,
|
|
});
|
|
views.push(view);
|
|
const cell = view.dom.querySelector(".cm-lineNumbers .cm-gutterElement")!;
|
|
cell.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
|
expect(cell.closest(".cm-gutters")?.getAttribute("title")).toMatch(/output classes without source code/);
|
|
for (const modifier of [{}, { altKey: true }, { shiftKey: true }]) {
|
|
cell.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, ...modifier }));
|
|
cell.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0, ...modifier }));
|
|
}
|
|
expect(breakpointsOf(path)).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("breakpoint look", () => {
|
|
it("shows the verified icon only while a session has bound it", () => {
|
|
const b = { id: "1", line: 0, enabled: true, condition: null, logMessage: null, hitCondition: null };
|
|
expect(lookOf(b, undefined, false, false)).toBe("normal");
|
|
expect(lookOf(b, { verified: true } as never, false, true)).toBe("verified");
|
|
expect(lookOf(b, { verified: false } as never, false, true)).toBe("unverified");
|
|
expect(lookOf({ ...b, enabled: false }, { verified: true } as never, false, true)).toBe("disabled");
|
|
});
|
|
});
|
|
|
|
describe("breakpoint click area", () => {
|
|
class Icon extends GutterMarker {
|
|
toDOM() {
|
|
return document.createElement("span");
|
|
}
|
|
}
|
|
|
|
it("toggles from empty space in other gutters but leaves their markers alone", () => {
|
|
localStorage.clear();
|
|
loadBreakpoints("/proj");
|
|
const view = new EditorView({
|
|
state: EditorState.create({
|
|
doc: "a\nb",
|
|
extensions: [
|
|
lineNumbers(),
|
|
gutter({
|
|
class: "extra",
|
|
renderEmptyElements: true,
|
|
lineMarker: (_v, line) => (line.from > 0 ? new Icon() : null),
|
|
}),
|
|
breakpointGutter(ctx),
|
|
],
|
|
}),
|
|
parent: document.body,
|
|
});
|
|
views.push(view);
|
|
const down = (el: Element) => {
|
|
el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
|
|
el.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0 }));
|
|
};
|
|
const cells = view.dom.querySelectorAll(".extra .cm-gutterElement");
|
|
down(cells[cells.length - 1]!.firstElementChild!);
|
|
expect(breakpointsOf(ctx.path)).toHaveLength(0);
|
|
down(cells[0]!);
|
|
expect(breakpointsOf(ctx.path)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("breakpoint gutter clicks", () => {
|
|
// Plain and modifier clicks act on release, so a drag over the gutter can select lines.
|
|
const click = (view: EditorView, init: MouseEventInit) => {
|
|
const cell = view.dom.querySelector(".cm-lineNumbers .cm-gutterElement:not([style*='hidden'])")!;
|
|
cell.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, ...init }));
|
|
cell.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0, ...init }));
|
|
};
|
|
|
|
it("⇧-click adds a non-suspending logging breakpoint, ⌥-click a temporary one", () => {
|
|
localStorage.clear();
|
|
loadBreakpoints("/proj");
|
|
const view = makeView("a\nb");
|
|
click(view, { shiftKey: true });
|
|
expect(breakpointsOf(ctx.path)[0]).toMatchObject({ line: 0, suspend: false, logHit: true });
|
|
// Modifier clicks leave an existing breakpoint alone; a plain click removes it.
|
|
click(view, { altKey: true });
|
|
click(view, { shiftKey: true });
|
|
expect(breakpointsOf(ctx.path)).toHaveLength(1);
|
|
click(view, {});
|
|
expect(breakpointsOf(ctx.path)).toHaveLength(0);
|
|
click(view, { altKey: true });
|
|
expect(breakpointsOf(ctx.path)[0]).toMatchObject({ line: 0, temporary: true });
|
|
});
|
|
|
|
it("middle-click enables / disables an existing breakpoint", () => {
|
|
localStorage.clear();
|
|
loadBreakpoints("/proj");
|
|
const view = makeView("a\nb");
|
|
click(view, { button: 1 });
|
|
expect(breakpointsOf(ctx.path)).toHaveLength(0);
|
|
toggleBreakpoint(ctx.path, 0);
|
|
click(view, { button: 1 });
|
|
expect(breakpointsOf(ctx.path)[0]!.enabled).toBe(false);
|
|
click(view, { button: 1 });
|
|
expect(breakpointsOf(ctx.path)[0]!.enabled).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("execution point", () => {
|
|
it("keeps the execution line synchronized if inline rendering throws", async () => {
|
|
useExecutionPoint.setState({ path: null, values: {} });
|
|
const errors: unknown[] = [];
|
|
const view = new EditorView({
|
|
state: EditorState.create({
|
|
doc: "x\nx\nx",
|
|
extensions: [executionPoint(ctx), EditorView.exceptionSink.of((e) => errors.push(e))],
|
|
}),
|
|
parent: document.body,
|
|
});
|
|
views.push(view);
|
|
const values: Record<string, string> = Object.defineProperty({}, "x", {
|
|
enumerable: true,
|
|
get() {
|
|
throw new Error("inline rendering failed");
|
|
},
|
|
});
|
|
useExecutionPoint.setState({ path: ctx.path, line: 0, top: true, values });
|
|
await flush();
|
|
expect(errors).toHaveLength(1);
|
|
useExecutionPoint.setState({ line: 2, values: {} });
|
|
await flush();
|
|
expect(view.dom.querySelectorAll(".cm-line")[2]!.className).toContain(styles.execLine);
|
|
useExecutionPoint.setState({ path: null });
|
|
await flush();
|
|
expect(view.dom.querySelector(`.${styles.execLine}`)).toBeNull();
|
|
});
|
|
|
|
it("keeps refreshing the execution line around Object.prototype names", async () => {
|
|
useExecutionPoint.setState({ path: null, values: {} });
|
|
const view = makeView("String text = Integer.toString(40);\nint count = 40;\ncount++;\ncount++;");
|
|
useExecutionPoint.setState({ path: ctx.path, line: 1, top: true, values: { text: "40" } });
|
|
await flush();
|
|
useExecutionPoint.setState({ line: 3, values: { text: "40", count: "42" } });
|
|
await flush();
|
|
expect(view.dom.querySelector(`.${styles.execLine}`)?.textContent).toContain("count++");
|
|
useExecutionPoint.setState({ path: null, values: {} });
|
|
await flush();
|
|
expect(view.dom.querySelector(`.${styles.execLine}`)).toBeNull();
|
|
});
|
|
|
|
it.each([2, null])("refreshes a cached editor state when remounted at %s", async (line) => {
|
|
useExecutionPoint.setState({ path: ctx.path, line: 0, top: true, values: {} });
|
|
const view = makeView("first\nsecond\nthird");
|
|
const state = view.state;
|
|
view.destroy();
|
|
views.splice(views.indexOf(view), 1);
|
|
useExecutionPoint.setState({ path: line == null ? null : ctx.path, line: line ?? 0, values: {} });
|
|
const restored = new EditorView({ state, parent: document.body });
|
|
views.push(restored);
|
|
await flush();
|
|
expect(restored.dom.querySelector(`.${styles.execLine}`)?.textContent ?? null).toBe(line == null ? null : "third");
|
|
});
|
|
|
|
it("highlights the suspended line and shows inline values", async () => {
|
|
const view = makeView("x = 1\ny = x + 1\nprint(y)");
|
|
useExecutionPoint.setState({ path: ctx.path, line: 1, top: true, values: { x: "1", y: "2" } });
|
|
await flush();
|
|
const line = view.dom.querySelectorAll(".cm-line")[1]!;
|
|
expect(line.className).toContain(styles.execLine);
|
|
const inline = [...view.dom.querySelectorAll(`.${styles.inlineValue}`)].map((e) => e.textContent);
|
|
expect(inline).toEqual(["x: 1", "y: 2, x: 1"]);
|
|
useExecutionPoint.setState({ path: null, values: {} });
|
|
await flush();
|
|
expect(view.dom.querySelector(`.${styles.execLine}`)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("session events", () => {
|
|
it("tracks pause / continue / termination and publishes context keys", async () => {
|
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
adoptSession(77);
|
|
handleDebugEvent(77, { kind: "output", category: "stdout", output: "hel" });
|
|
handleDebugEvent(77, { kind: "output", category: "stdout", output: "lo\n" });
|
|
await flushFrame();
|
|
expect(useDebug.getState().console[77]!.map((l) => l.text)).toEqual(["hello\n"]);
|
|
expect(getContext().debugSessionActive).toBe(true);
|
|
handleDebugEvent(77, {
|
|
kind: "stopped",
|
|
threadId: 1,
|
|
reason: "breakpoint",
|
|
description: null,
|
|
allThreadsStopped: true,
|
|
});
|
|
const s = useDebug.getState().sessions[77]!;
|
|
expect(s.status).toBe("paused");
|
|
expect(s.hits).toBe(1);
|
|
expect(getContext().debugSessionPaused).toBe(true);
|
|
handleDebugEvent(77, { kind: "continued", threadId: 1, allThreadsContinued: true });
|
|
expect(useDebug.getState().sessions[77]!.status).toBe("running");
|
|
expect(getContext().debugSessionPaused).toBe(false);
|
|
handleDebugEvent(77, { kind: "exited", code: 0 });
|
|
handleDebugEvent(77, { kind: "terminated" });
|
|
expect(useDebug.getState().sessions[77]!.status).toBe("terminated");
|
|
expect(getContext().debugSessionActive).toBe(false);
|
|
});
|
|
|
|
it("ignores sessions this window did not start, but adopts children of its own", async () => {
|
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
adoptSession(90, { name: "app", adapter: "js-debug" });
|
|
// Another window's session: no session, no console, no tool window.
|
|
handleDebugEvent(91, { kind: "started", name: "other", adapter: "debugpy", parent: null });
|
|
handleDebugEvent(91, {
|
|
kind: "stopped",
|
|
threadId: 1,
|
|
reason: "breakpoint",
|
|
description: null,
|
|
allThreadsStopped: true,
|
|
});
|
|
handleDebugEvent(91, { kind: "output", category: "stdout", output: "x\n" });
|
|
expect(useDebug.getState().sessions[91]).toBeUndefined();
|
|
expect(useDebug.getState().console[91]).toBeUndefined();
|
|
// A child of someone else's session is not ours either.
|
|
handleDebugEvent(93, { kind: "started", name: "child", adapter: "debugpy", parent: 91 });
|
|
expect(useDebug.getState().sessions[93]).toBeUndefined();
|
|
// A child the adapter of our session started is adopted with its parent.
|
|
handleDebugEvent(92, { kind: "started", name: "worker", adapter: "js-debug", parent: 90 });
|
|
handleDebugEvent(92, { kind: "output", category: "stdout", output: "child\n" });
|
|
await flushFrame();
|
|
const child = useDebug.getState().sessions[92]!;
|
|
expect([child.name, child.parent, child.status]).toEqual(["worker", 90, "running"]);
|
|
expect(useDebug.getState().console[92]!.map((l) => l.text)).toEqual(["child\n"]);
|
|
handleDebugEvent(92, { kind: "terminated" });
|
|
handleDebugEvent(90, { kind: "terminated" });
|
|
expect(getContext().debugSessionActive).toBe(false);
|
|
});
|
|
});
|