From dfbd666dfd06724e2c414e29ec1592123ff6293a Mon Sep 17 00:00:00 2001 From: Xinyuan Lin Date: Sat, 8 Aug 2026 00:41:21 -0700 Subject: [PATCH] test(frontend): cover the debugger's breakpoint gutter setupMonacoBreakpointMethods was the component's one uncovered block. The existing suite stubs it out because the minimal editor mock cannot back a real MonacoBreakpoint, so neither of the two overrides it installs was exercised. Adds 13 tests over both. The glyph override: a plain breakpoint, one carrying a condition, a whitespace-only condition that must not claim to be conditional, a hovered line, and the condition being looked up on the line the glyph starts at rather than where its range ends. The mouse-down override: the library's own handler is disposed before the replacement registers, since two live handlers would add and immediately remove a breakpoint on one click; a left click toggles; a right click opens the condition input instead of toggling, and only for a line that has a breakpoint; clicks below the last line and outside the gutter do nothing. The stand-in editor answers any unstubbed on* listener with an inert disposable, so it does not have to track which events monaco-breakpoints subscribes to. No production file is touched. --- .../code-debugger.component.spec.ts | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) diff --git a/frontend/src/app/workspace/component/code-editor-dialog/code-debugger.component.spec.ts b/frontend/src/app/workspace/component/code-editor-dialog/code-debugger.component.spec.ts index cac2331de89..1d0ce86d249 100644 --- a/frontend/src/app/workspace/component/code-editor-dialog/code-debugger.component.spec.ts +++ b/frontend/src/app/workspace/component/code-editor-dialog/code-debugger.component.spec.ts @@ -30,6 +30,7 @@ import { commonTestProviders } from "../../../common/testing/test-utils"; import type { Mocked } from "vitest"; import type { MonacoBreakpoint } from "monaco-breakpoints"; import type * as monaco from "monaco-editor"; +import * as monacoNs from "monaco-editor"; describe("CodeDebuggerComponent", () => { let component: CodeDebuggerComponent; let fixture: ComponentFixture; @@ -392,3 +393,215 @@ describe("CodeDebuggerComponent", () => { }); }); }); +/** + * setupMonacoBreakpointMethods is the one uncovered block in the component: the suite above stubs it + * out because the minimal editor mock cannot back a real MonacoBreakpoint. It decides which gutter + * glyph a line gets and what a gutter click does, so it is worth driving directly. + */ +describe("CodeDebuggerComponent breakpoint gutter", () => { + const GUTTER = monacoNs.editor.MouseTargetType.GUTTER_GLYPH_MARGIN; + const CONTENT_TEXT = monacoNs.editor.MouseTargetType.CONTENT_TEXT; + + let component: CodeDebuggerComponent; + let fixture: ComponentFixture; + let debugService: { + getDebugState: ReturnType; + doModifyBreakpoint: ReturnType; + getCondition: ReturnType; + }; + let editor: any; + let mouseDown: ((evt: any) => void) | undefined; + let firstMouseDownDisposable: { dispose: ReturnType } | undefined; + + const operatorId = "op-under-debug"; + + /** + * A stand-in editor exposing only what MonacoBreakpoint and the override touch. Every listener + * hands back its own spy disposable so the dispose-before-reregister step is observable. + */ + function makeEditor() { + const disposable = () => ({ dispose: vi.fn() }); + const model = { + deltaDecorations: vi.fn(() => [] as string[]), + getLineCount: vi.fn(() => 100), + getValue: vi.fn(() => ""), + onDidChangeContent: vi.fn(disposable), + uri: { toString: () => "inmemory://model/1" }, + }; + let downCount = 0; + const base: Record = { + onMouseDown: vi.fn((h: (evt: any) => void) => { + downCount += 1; + const d = disposable(); + if (downCount === 1) { + // The MonacoBreakpoint constructor registers first; the override replaces it. + firstMouseDownDisposable = d; + } else { + mouseDown = h; + } + return d; + }), + getModel: vi.fn(() => model), + deltaDecorations: vi.fn(() => [] as string[]), + createDecorationsCollection: vi.fn(() => ({ set: vi.fn(), clear: vi.fn() })), + dispose: vi.fn(), + }; + // MonacoBreakpoint subscribes to a handful of editor events whose exact set is its own business; + // anything named on* that is not explicitly stubbed above yields an inert disposable. + const editor = new Proxy(base, { + get(target, prop) { + if (prop in target) { + return target[prop as string]; + } + if (typeof prop === "string" && prop.startsWith("on")) { + return () => disposable(); + } + return undefined; + }, + has() { + return true; + }, + }); + return { model, editor }; + } + + function gutterClick(lineNumber: number, opts: { leftButton?: boolean; isAfterLines?: boolean; type?: number } = {}) { + return { + target: { + type: opts.type ?? GUTTER, + detail: { isAfterLines: opts.isAfterLines ?? false }, + position: { lineNumber }, + }, + event: { leftButton: opts.leftButton ?? true }, + }; + } + + /** Calls the installed override the way monaco-breakpoints would. exists=true means Exist. */ + function glyphFor(lineNumber: number, exists: boolean): string { + const range = { startLineNumber: lineNumber, endLineNumber: lineNumber + 5 } as any; + const decoration = (component.monacoBreakpoint as any)["createBreakpointDecoration"](range, exists ? 0 : 1); + return decoration.options.glyphMarginClassName; + } + + beforeEach(async () => { + mouseDown = undefined; + firstMouseDownDisposable = undefined; + debugService = { + getDebugState: vi.fn(() => new Y.Doc().getMap("debug")), + doModifyBreakpoint: vi.fn(), + getCondition: vi.fn(() => undefined), + }; + + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [CodeDebuggerComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + providers: [ + { + provide: WorkflowStatusService, + useValue: { getStatusUpdateStream: vi.fn(() => new Subject().asObservable()) }, + }, + { provide: UdfDebugService, useValue: debugService }, + ...commonTestProviders, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CodeDebuggerComponent); + component = fixture.componentInstance; + component.currentOperatorId = operatorId; + + const fake = makeEditor(); + editor = fake.editor; + component.monacoEditor = editor; + component.setupMonacoBreakpointMethods(editor); + }); + + describe("gutter glyph", () => { + it("marks a plain breakpoint", () => { + expect(glyphFor(3, true)).toBe("monaco-breakpoint"); + }); + + it("marks a breakpoint that carries a condition", () => { + debugService.getCondition.mockReturnValue("count > 1"); + + expect(glyphFor(3, true)).toBe("monaco-conditional-breakpoint"); + }); + + it("does not treat a blank condition as a condition", () => { + // A condition input left as whitespace must render as an ordinary breakpoint, otherwise the + // gutter claims a condition the debugger will not apply. + debugService.getCondition.mockReturnValue(" "); + + expect(glyphFor(3, true)).toBe("monaco-breakpoint"); + }); + + it("marks a line that is only being hovered", () => { + expect(glyphFor(3, false)).toBe("monaco-hover-breakpoint"); + }); + + it("looks the condition up on the line the glyph starts at", () => { + // The range spans several lines; reading endLineNumber would attribute another line's + // condition to this glyph. + glyphFor(7, true); + + expect(debugService.getCondition).toHaveBeenCalledWith(operatorId, 7); + }); + + it("keys the lookup to the operator being debugged", () => { + glyphFor(2, true); + + expect(debugService.getCondition).toHaveBeenCalledWith(operatorId, expect.anything()); + }); + }); + + describe("gutter clicks", () => { + it("replaces the library's own mouse-down handler rather than adding to it", () => { + // Two live handlers would both toggle, so a single click would add and immediately remove a + // breakpoint. + expect(firstMouseDownDisposable!.dispose).toHaveBeenCalledTimes(1); + expect(editor.onMouseDown).toHaveBeenCalledTimes(2); + }); + + it("toggles the breakpoint on a left click in the gutter", () => { + mouseDown!(gutterClick(12)); + + expect(debugService.doModifyBreakpoint).toHaveBeenCalledWith(operatorId, 12); + }); + + it("ignores a click below the last line", () => { + mouseDown!(gutterClick(12, { isAfterLines: true })); + + expect(debugService.doModifyBreakpoint).not.toHaveBeenCalled(); + }); + + it("ignores a click that is not on the gutter", () => { + mouseDown!(gutterClick(12, { type: CONTENT_TEXT })); + + expect(debugService.doModifyBreakpoint).not.toHaveBeenCalled(); + }); + + it("does not toggle on a right click", () => { + // The right button opens the condition input instead; toggling as well would remove the + // breakpoint the user is trying to annotate. + mouseDown!(gutterClick(12, { leftButton: false })); + + expect(debugService.doModifyBreakpoint).not.toHaveBeenCalled(); + }); + + it("opens the condition input for a right click on an existing breakpoint", () => { + (component.monacoBreakpoint as any)["lineNumberAndDecorationIdMap"] = new Map([[12, "dec-1"]]); + + mouseDown!(gutterClick(12, { leftButton: false })); + + expect(component.breakpointConditionLine).toBe(12); + }); + + it("opens nothing for a right click on a line with no breakpoint", () => { + (component.monacoBreakpoint as any)["lineNumberAndDecorationIdMap"] = new Map(); + + mouseDown!(gutterClick(12, { leftButton: false })); + + expect(component.breakpointConditionLine).not.toBe(12); + }); + }); +});