This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/texera.git
commit fb1a5c4255f03e195e4ff36821d29acb71348071 Author: Xinyuan Lin <[email protected]> AuthorDate: Sun Aug 9 02:27:00 2026 -0700 test(frontend): cover the debugger's breakpoint gutter (#7425) ### What changes were proposed in this PR? `setupMonacoBreakpointMethods` was the component's one uncovered block. The existing suite stubs it out — with a comment saying so — 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** decides what the gutter shows: ``` exists && condition present -> monaco-conditional-breakpoint exists && no condition -> monaco-breakpoint hovering only -> monaco-hover-breakpoint ``` Covered including the `Boolean(condition?.trim())` guard — a condition left as whitespace must render as an ordinary breakpoint rather than claiming a condition the debugger will not apply — and the lookup happening at `range.startLineNumber`, since reading `endLineNumber` would attribute another line's condition to this glyph. **The mouse-down override** replaces the library's own handler. The `dispose()` before re-registering is load-bearing: two live handlers would add and immediately remove a breakpoint on a single click. A left click toggles; a right click opens the condition input instead of toggling, and only for a line that already has a breakpoint; clicks below the last line and outside the gutter do nothing. **Verified by mutation**, all reverted (production diff empty): | Mutation | Result | |---|---| | treat a blank condition as a condition | red | | read the condition from the range's end line | red | | key the condition lookup to a fixed operator | red | | swap the conditional and plain glyphs | red | | swap the exists and hover arms | red | | skip disposing the previous mouse-down handler | red | | drop the gutter target-type check | red | | drop the after-lines guard | red | | invert the left/right button branch | red | The stand-in editor is a `Proxy` that answers any unstubbed `on*` listener with an inert disposable, so the spec does not have to track which events `monaco-breakpoints` subscribes to — the first attempt failed on `onDidChangeCursorPosition`, and guessing at the rest would have been fragile. No production file is touched. ### Any related issues, documentation, discussions? Closes #7422 ### How was this PR tested? ``` npx ng test --watch=false --include="**/code-debugger.component.spec.ts" ``` ``` Test Files 1 passed (1) Tests 29 passed (29) ``` 13 new on top of the existing 16. `yarn format:ci` passes. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 5) --- .../code-debugger.component.spec.ts | 220 +++++++++++++++++++++ 1 file changed, 220 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 cac2331de8..dbbfe40e10 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<CodeDebuggerComponent>; @@ -392,3 +393,222 @@ 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<CodeDebuggerComponent>; + let debugService: { + getDebugState: ReturnType<typeof vi.fn>; + doModifyBreakpoint: ReturnType<typeof vi.fn>; + getCondition: ReturnType<typeof vi.fn>; + }; + let editor: any; + let mouseDown: ((evt: any) => void) | undefined; + let firstMouseDownDisposable: { dispose: ReturnType<typeof vi.fn> } | 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<string, unknown> = { + 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<BreakpointInfo>("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); + }); + + afterEach(() => { + // This suite drives a real MonacoBreakpoint, so tear it down the way the component does. Left + // alive, its editor listeners would outlive the test that registered them. + component.removeMonacoBreakpointMethods(); + component.monacoEditor?.dispose(); + }); + + 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).toBeUndefined(); + }); + }); +});
