diff --git a/core/amber/src/main/python/core/architecture/handlers/control/debug_command_handler.py b/core/amber/src/main/python/core/architecture/handlers/control/debug_command_handler.py index 7a31f1a3b02..b412c08cdd8 100644 --- a/core/amber/src/main/python/core/architecture/handlers/control/debug_command_handler.py +++ b/core/amber/src/main/python/core/architecture/handlers/control/debug_command_handler.py @@ -17,6 +17,8 @@ def __call__(self, context: Context, command: cmd, *args, **kwargs): context.debug_manager.put_debug_command(translated_command) # allow MainLoop to switch into DataProcessor. + context.pause_manager.resume(PauseType.USER_PAUSE) + context.pause_manager.resume(PauseType.EXCEPTION_PAUSE) context.pause_manager.resume(PauseType.DEBUG_PAUSE) @staticmethod diff --git a/core/amber/src/main/python/core/architecture/handlers/control/replay_current_tuple_handler.py b/core/amber/src/main/python/core/architecture/handlers/control/replay_current_tuple_handler.py index 8efd04e9ee7..6543c8cb895 100644 --- a/core/amber/src/main/python/core/architecture/handlers/control/replay_current_tuple_handler.py +++ b/core/amber/src/main/python/core/architecture/handlers/control/replay_current_tuple_handler.py @@ -20,5 +20,6 @@ def __call__(self, context: Context, command: cmd, *args, **kwargs): [context.tuple_processing_manager.current_input_tuple], context.tuple_processing_manager.current_input_tuple_iter, ) + context.pause_manager.resume(PauseType.USER_PAUSE) context.pause_manager.resume(PauseType.EXCEPTION_PAUSE) return None diff --git a/core/amber/src/main/python/core/architecture/managers/pause_manager.py b/core/amber/src/main/python/core/architecture/managers/pause_manager.py index 307640f8b7d..4ddf2b7a85d 100644 --- a/core/amber/src/main/python/core/architecture/managers/pause_manager.py +++ b/core/amber/src/main/python/core/architecture/managers/pause_manager.py @@ -53,7 +53,6 @@ def pause_input_channel( raise NotImplementedError() def resume(self, pause_type: PauseType, change_state=True) -> None: - logger.debug("resume by " + str(pause_type)) if pause_type in self._global_pauses: self._global_pauses.remove(pause_type) # del self._specific_input_pauses[pause_type] diff --git a/core/amber/src/main/scala/edu/uci/ics/amber/engine/architecture/controller/promisehandlers/ConsoleMessageHandler.scala b/core/amber/src/main/scala/edu/uci/ics/amber/engine/architecture/controller/promisehandlers/ConsoleMessageHandler.scala index e1a6760aebc..7de1c4110b0 100644 --- a/core/amber/src/main/scala/edu/uci/ics/amber/engine/architecture/controller/promisehandlers/ConsoleMessageHandler.scala +++ b/core/amber/src/main/scala/edu/uci/ics/amber/engine/architecture/controller/promisehandlers/ConsoleMessageHandler.scala @@ -2,10 +2,8 @@ package edu.uci.ics.amber.engine.architecture.controller.promisehandlers import edu.uci.ics.amber.engine.architecture.controller.ControllerAsyncRPCHandlerInitializer import edu.uci.ics.amber.engine.architecture.controller.promisehandlers.ConsoleMessageHandler.ConsoleMessageTriggered -import edu.uci.ics.amber.engine.architecture.controller.promisehandlers.PauseHandler.PauseWorkflow import edu.uci.ics.amber.engine.architecture.worker.controlcommands.ConsoleMessage import edu.uci.ics.amber.engine.common.rpc.AsyncRPCServer.ControlCommand -import edu.uci.ics.amber.engine.common.virtualidentity.util.CONTROLLER object ConsoleMessageHandler { case class ConsoleMessageTriggered(consoleMessage: ConsoleMessage) extends ControlCommand[Unit] @@ -15,11 +13,6 @@ trait ConsoleMessageHandler { this: ControllerAsyncRPCHandlerInitializer => registerHandler[ConsoleMessageTriggered, Unit] { (msg, sender) => { - if (msg.consoleMessage.msgType.isError) { - // if its an error message, pause the workflow - execute(PauseWorkflow(), CONTROLLER) - } - // forward message to frontend sendToClient(msg) } diff --git a/core/gui/custom-webpack.config.js b/core/gui/custom-webpack.config.js index 0082a81e884..c39389135af 100644 --- a/core/gui/custom-webpack.config.js +++ b/core/gui/custom-webpack.config.js @@ -4,15 +4,18 @@ module.exports = { { test: /\.css$/, use: ["style-loader", "css-loader"], - include: [require("path").resolve(__dirname, "node_modules/monaco-editor")], + include: [ + require("path").resolve(__dirname, "node_modules/monaco-editor"), + require("path").resolve(__dirname, "node_modules/monaco-breakpoints") + ], }, ], // this is required for loading .wasm (and other) files. // For context, see https://stackoverflow.com/a/75252098 and https://github.com/angular/angular-cli/issues/24617 parser: { javascript: { - url: true - } - } + url: true, + }, + }, }, }; diff --git a/core/gui/package.json b/core/gui/package.json index 9ec2eb34c1e..a9b3b0d8bc3 100644 --- a/core/gui/package.json +++ b/core/gui/package.json @@ -58,6 +58,7 @@ "jszip": "3.10.1", "lodash-es": "4.17.21", "marked": "4.3.0", + "monaco-breakpoints": "0.2.0", "monaco-editor": "npm:@codingame/monaco-vscode-editor-api@8.0.4", "monaco-editor-wrapper": "5.5.3", "monaco-languageclient": "8.8.3", diff --git a/core/gui/src/app/app.module.ts b/core/gui/src/app/app.module.ts index 2f7d24b55df..f1c683f9f58 100644 --- a/core/gui/src/app/app.module.ts +++ b/core/gui/src/app/app.module.ts @@ -138,6 +138,8 @@ import { HubWorkflowResultComponent } from "./hub/component/workflow/result/hub- import { HubWorkflowComponent } from "./hub/component/workflow/hub-workflow.component"; import { HubWorkflowSearchBarComponent } from "./hub/component/workflow/search-bar/hub-workflow-search-bar.component"; import { HubWorkflowDetailComponent } from "./hub/component/workflow/detail/hub-workflow-detail.component"; +import { BreakpointConditionInputComponent } from "./workspace/component/code-editor-dialog/breakpoint-condition-input/breakpoint-condition-input.component"; +import { CodeDebuggerComponent } from "./workspace/component/code-editor-dialog/code-debugger.component"; registerLocaleData(en); @@ -226,6 +228,8 @@ registerLocaleData(en); HubWorkflowDetailComponent, HubWorkflowResultComponent, GoogleLoginComponent, + BreakpointConditionInputComponent, + CodeDebuggerComponent, ], imports: [ BrowserModule, diff --git a/core/gui/src/app/workspace/component/code-editor-dialog/breakpoint-condition-input/breakpoint-condition-input.component.html b/core/gui/src/app/workspace/component/code-editor-dialog/breakpoint-condition-input/breakpoint-condition-input.component.html new file mode 100644 index 00000000000..5874e42bb92 --- /dev/null +++ b/core/gui/src/app/workspace/component/code-editor-dialog/breakpoint-condition-input/breakpoint-condition-input.component.html @@ -0,0 +1,13 @@ +
+
Condition on line {{ lineNum }}:
+ + {{ conditionTextarea.focus() }} +
diff --git a/core/gui/src/app/workspace/component/code-editor-dialog/breakpoint-condition-input/breakpoint-condition-input.component.scss b/core/gui/src/app/workspace/component/code-editor-dialog/breakpoint-condition-input/breakpoint-condition-input.component.scss new file mode 100644 index 00000000000..b283b6ec022 --- /dev/null +++ b/core/gui/src/app/workspace/component/code-editor-dialog/breakpoint-condition-input/breakpoint-condition-input.component.scss @@ -0,0 +1,59 @@ +.condition-input-popup { + position: absolute; + background: #333; + color: #fff; + padding: 5px; /* Larger padding for better UX */ + border-radius: 3px; /* Smoother rounded corners */ + z-index: 1000; + pointer-events: auto; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + display: flex; + flex-direction: column; + align-items: flex-start; + width: 240px; + opacity: 1; + transition: opacity 1s ease-in-out; +} + +.condition-input-popup .tooltip-header { + font-weight: bold; + margin-bottom: 5px; + white-space: nowrap; + flex-shrink: 0; +} + +.condition-input-popup .condition-textarea { + width: 100%; + height: 50px; + resize: vertical; + background-color: transparent; + color: white; + border: 1px solid #ccc; + padding: 5px; +} + +.condition-input-popup.fade-out { + opacity: 0; + pointer-events: none; +} + +::ng-deep .cgmr.codicon.monaco-conditional-breakpoint { + width: 10px !important; + height: 10px !important; + border-radius: 100%; + background-color: #d47d78; + margin: 4px 0 0 8px; + cursor: pointer; + color: #000; + text-align: center; + font-size: 8px; + font-weight: bold; +} + +::ng-deep .cgmr.codicon.monaco-conditional-breakpoint::before { + content: "?"; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} diff --git a/core/gui/src/app/workspace/component/code-editor-dialog/breakpoint-condition-input/breakpoint-condition-input.component.spec.ts b/core/gui/src/app/workspace/component/code-editor-dialog/breakpoint-condition-input/breakpoint-condition-input.component.spec.ts new file mode 100644 index 00000000000..d7e2f76e621 --- /dev/null +++ b/core/gui/src/app/workspace/component/code-editor-dialog/breakpoint-condition-input/breakpoint-condition-input.component.spec.ts @@ -0,0 +1,100 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { BreakpointConditionInputComponent } from "./breakpoint-condition-input.component"; +import { UdfDebugService } from "../../../service/operator-debug/udf-debug.service"; +import { SimpleChanges } from "@angular/core"; +import * as monaco from "monaco-editor"; + +describe("BreakpointConditionInputComponent", () => { + let component: BreakpointConditionInputComponent; + let fixture: ComponentFixture; + let mockUdfDebugService: jasmine.SpyObj; + let editorElement: HTMLElement; + + beforeEach(async () => { + // Create a mock UdfDebugService + mockUdfDebugService = jasmine.createSpyObj("UdfDebugService", ["getCondition", "doUpdateBreakpointCondition"]); + + await TestBed.configureTestingModule({ + declarations: [BreakpointConditionInputComponent], + providers: [{ provide: UdfDebugService, useValue: mockUdfDebugService }], + }).compileComponents(); + + fixture = TestBed.createComponent(BreakpointConditionInputComponent); + component = fixture.componentInstance; + + // Create and attach a
to host the Monaco editor + editorElement = document.createElement("div"); + editorElement.style.width = "800px"; + editorElement.style.height = "600px"; + document.body.appendChild(editorElement); // Attach to the DOM + + // Initialize the Monaco editor + component.monacoEditor = monaco.editor.create(editorElement, { + value: "function hello() {\n\tconsole.log(\"Hello, world!\");\n}", + language: "javascript", + }); + + // Set required inputs + component.operatorId = "test-operator"; + component.lineNum = 1; + + fixture.detectChanges(); // Trigger Angular's change detection + }); + + afterEach(() => { + // Clean up the editor and DOM element after each test + component.monacoEditor.dispose(); + editorElement.remove(); + component.closeEmitter.emit(); + }); + + it("should create the component", () => { + expect(component).toBeTruthy(); + }); + + it("should update the condition when lineNum changes", () => { + mockUdfDebugService.getCondition.and.returnValue("existing condition"); + + const changes: SimpleChanges = { + lineNum: { + currentValue: 2, + previousValue: 1, + firstChange: false, + isFirstChange: () => false, + }, + }; + + component.ngOnChanges(changes); + + expect(component.condition).toBe("existing condition"); + }); + + it("should handle Enter key event and save the condition", () => { + const emitSpy = spyOn(component.closeEmitter, "emit"); + const event = new KeyboardEvent("keydown", { key: "Enter" }); + + component.condition = " new condition "; + component.handleEvent(event); + + expect(mockUdfDebugService.doUpdateBreakpointCondition).toHaveBeenCalledWith("test-operator", 1, "new condition"); + expect(emitSpy).toHaveBeenCalled(); + }); + + it("should not handle Enter key event if shift key is pressed", () => { + const emitSpy = spyOn(component.closeEmitter, "emit"); + const event = new KeyboardEvent("keydown", { key: "Enter", shiftKey: true }); + + component.handleEvent(event); + + expect(mockUdfDebugService.doUpdateBreakpointCondition).not.toHaveBeenCalled(); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it("should emit close event on focusout", () => { + const emitSpy = spyOn(component.closeEmitter, "emit"); + + component.handleEvent(); // Simulate focusout + + expect(emitSpy).toHaveBeenCalled(); + }); +}); diff --git a/core/gui/src/app/workspace/component/code-editor-dialog/breakpoint-condition-input/breakpoint-condition-input.component.ts b/core/gui/src/app/workspace/component/code-editor-dialog/breakpoint-condition-input/breakpoint-condition-input.component.ts new file mode 100644 index 00000000000..54d0c754f40 --- /dev/null +++ b/core/gui/src/app/workspace/component/code-editor-dialog/breakpoint-condition-input/breakpoint-condition-input.component.ts @@ -0,0 +1,101 @@ +import { + AfterViewChecked, + Component, + ElementRef, + EventEmitter, + HostListener, + Input, + OnChanges, + Output, + SimpleChanges, + ViewChild, +} from "@angular/core"; +import { UdfDebugService } from "../../../service/operator-debug/udf-debug.service"; +import { isDefined } from "../../../../common/util/predicate"; +import { MonacoEditor } from "monaco-breakpoints/dist/types"; + +/** + * This component is a dialog that allows users to input a condition for a breakpoint. + */ +@Component({ + selector: "texera-breakpoint-condition-input", + templateUrl: "./breakpoint-condition-input.component.html", + styleUrls: ["./breakpoint-condition-input.component.scss"], +}) +export class BreakpointConditionInputComponent implements OnChanges { + constructor(private udfDebugService: UdfDebugService) {} + + @Input() operatorId = ""; + @Input() lineNum?: number; + @Input() monacoEditor!: MonacoEditor; + @Output() closeEmitter = new EventEmitter(); + public condition = ""; + public topPosition: string = "0px"; + public leftPosition: string = "0px"; + ngOnChanges(changes: SimpleChanges): void { + if (!isDefined(changes["lineNum"]?.currentValue)) { + return; + } + // when the line number changes, update the condition + this.condition = this.udfDebugService.getCondition(this.operatorId, this.lineNum!) ?? ""; + + // update position + const layoutInfo = this.monacoEditor.getLayoutInfo(); + const editorRect = this.monacoEditor.getDomNode()?.getBoundingClientRect(); + const topValue = + (editorRect?.top || 0) + + this.monacoEditor.getBottomForLineNumber(this.lineNum!) - + this.monacoEditor.getScrollTop(); + const leftValue = (editorRect?.left || 0) + (layoutInfo?.glyphMarginLeft || 0) - 160; + this.topPosition = `${topValue}px`; + this.leftPosition = `${leftValue}px`; + } + + public left(): number { + if (!isDefined(this.monacoEditor)) { + return 0; + } + + // Calculate the left position of the input popup based on the editor layout + const { glyphMarginLeft } = this.monacoEditor.getLayoutInfo()!; + const { left } = this.monacoEditor.getDomNode()!.getBoundingClientRect(); + return left + glyphMarginLeft - this.monacoEditor.getScrollLeft() - 160; + } + + public top(): number { + if (!(isDefined(this.monacoEditor) && isDefined(this.lineNum))) { + return 0; + } + + // Calculate the top position of the input popup based on the editor layout + const topPixel = this.monacoEditor.getBottomForLineNumber(this.lineNum); + const editorRect = this.monacoEditor.getDomNode()?.getBoundingClientRect(); + return (editorRect?.top || 0) + topPixel - this.monacoEditor.getScrollTop(); + } + + get isVisible(): boolean { + return isDefined(this.lineNum); + } + + /** + * Update the condition and close the dialog when the user presses Enter or focus out. + * @param event the keyboard event, or undefined if the event is focus out. + */ + @HostListener("window:keydown", ["$event"]) + @HostListener("focusout") + handleEvent(event?: KeyboardEvent): void { + if (!this.lineNum || (event && !(event.key === "Enter" && !event.shiftKey))) { + // perform no changes if no line number or the key is not Enter + return; + } + + // prevent the default behavior of the Enter key + event?.preventDefault(); + + // save the updated condition + this.udfDebugService.doUpdateBreakpointCondition(this.operatorId, this.lineNum, this.condition.trim()); + + // close the dialog + this.closeEmitter.emit(); + } +} diff --git a/core/gui/src/app/workspace/component/code-editor-dialog/code-debugger.component.html b/core/gui/src/app/workspace/component/code-editor-dialog/code-debugger.component.html new file mode 100644 index 00000000000..d61b21ec553 --- /dev/null +++ b/core/gui/src/app/workspace/component/code-editor-dialog/code-debugger.component.html @@ -0,0 +1,6 @@ + + diff --git a/core/gui/src/app/workspace/component/code-editor-dialog/code-debugger.component.spec.ts b/core/gui/src/app/workspace/component/code-editor-dialog/code-debugger.component.spec.ts new file mode 100644 index 00000000000..1839ca15cc2 --- /dev/null +++ b/core/gui/src/app/workspace/component/code-editor-dialog/code-debugger.component.spec.ts @@ -0,0 +1,191 @@ +import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; +import { CodeDebuggerComponent } from "./code-debugger.component"; +import { WorkflowStatusService } from "../../service/workflow-status/workflow-status.service"; +import { UdfDebugService } from "../../service/operator-debug/udf-debug.service"; +import { Subject } from "rxjs"; +import * as Y from "yjs"; +import { BreakpointInfo } from "../../types/workflow-common.interface"; +import { OperatorState, OperatorStatistics } from "../../types/execute-workflow.interface"; +import * as monaco from "monaco-editor"; + +describe("CodeDebuggerComponent", () => { + let component: CodeDebuggerComponent; + let fixture: ComponentFixture; + + let mockWorkflowStatusService: jasmine.SpyObj; + let mockUdfDebugService: jasmine.SpyObj; + + let statusUpdateStream: Subject>; + let debugState: Y.Map; + + const operatorId = "test-operator-id"; + + beforeEach(async () => { + // Initialize streams and spy objects + statusUpdateStream = new Subject>(); + debugState = new Y.Map(); + + mockWorkflowStatusService = jasmine.createSpyObj("WorkflowStatusService", ["getStatusUpdateStream"]); + mockWorkflowStatusService.getStatusUpdateStream.and.returnValue(statusUpdateStream.asObservable()); + + mockUdfDebugService = jasmine.createSpyObj("UdfDebugService", ["getDebugState", "doModifyBreakpoint"]); + mockUdfDebugService.getDebugState.and.returnValue(debugState); + + await TestBed.configureTestingModule({ + declarations: [CodeDebuggerComponent], + providers: [ + { provide: WorkflowStatusService, useValue: mockWorkflowStatusService }, + { provide: UdfDebugService, useValue: mockUdfDebugService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CodeDebuggerComponent); + component = fixture.componentInstance; + + // Set required input properties + component.currentOperatorId = operatorId; + // Create and attach a
for Monaco editor + const editorElement = document.createElement("div"); + editorElement.id = "editor-container"; + editorElement.style.width = "800px"; + editorElement.style.height = "600px"; + document.body.appendChild(editorElement); // Attach to document body + + // Initialize the Monaco editor with the created element + component.monacoEditor = monaco.editor.create(editorElement, { + value: "function hello() {\n\tconsole.log(\"Hello, world!\");\n}", + language: "javascript", + }); + + // Trigger change detection to ensure view updates + fixture.detectChanges(); + }); + + afterEach(() => { + // Clean up streams to prevent memory leaks + statusUpdateStream.complete(); + }); + + it("should create the component", () => { + expect(component).toBeTruthy(); + }); + + it("should setup monaco breakpoint methods when state is Running", fakeAsync(() => { + const setupSpy = spyOn(component, "setupMonacoBreakpointMethods").and.callThrough(); + const rerenderSpy = spyOn(component, "rerenderExistingBreakpoints").and.callThrough(); + + // Emit a Running state event + statusUpdateStream.next({ + [operatorId]: { operatorState: OperatorState.Running, aggregatedOutputRowCount: 0, aggregatedInputRowCount: 0 }, + }); + + tick(); + fixture.detectChanges(); // Trigger change detection + + expect(setupSpy).toHaveBeenCalled(); + expect(rerenderSpy).toHaveBeenCalled(); + + // Emit the same state again (should not trigger setup again) + statusUpdateStream.next({ + [operatorId]: { operatorState: OperatorState.Running, aggregatedOutputRowCount: 0, aggregatedInputRowCount: 0 }, + }); + + tick(); + fixture.detectChanges(); // Trigger change detection + + expect(setupSpy).toHaveBeenCalledTimes(1); // No additional call + expect(rerenderSpy).toHaveBeenCalledTimes(1); // No additional call + + // Emit the paused state (should not trigger setup) + statusUpdateStream.next({ + [operatorId]: { operatorState: OperatorState.Paused, aggregatedOutputRowCount: 0, aggregatedInputRowCount: 0 }, + }); + + tick(); + fixture.detectChanges(); // Trigger change detection + + expect(setupSpy).toHaveBeenCalledTimes(1); // No additional call + expect(rerenderSpy).toHaveBeenCalledTimes(1); // No additional call + + // Emit the running state once more (should not trigger setup) + statusUpdateStream.next({ + [operatorId]: { operatorState: OperatorState.Paused, aggregatedOutputRowCount: 0, aggregatedInputRowCount: 0 }, + }); + + tick(); + fixture.detectChanges(); // Trigger change detection + + expect(setupSpy).toHaveBeenCalledTimes(1); // No additional call + expect(rerenderSpy).toHaveBeenCalledTimes(1); // No additional call + })); + + it("should remove monaco breakpoint methods when state changes to Uninitialized", () => { + const removeSpy = spyOn(component, "removeMonacoBreakpointMethods").and.callThrough(); + + // Emit an Uninitialized state event + statusUpdateStream.next({ + [operatorId]: { + operatorState: OperatorState.Uninitialized, + aggregatedOutputRowCount: 0, + aggregatedInputRowCount: 0, + }, + }); + + fixture.detectChanges(); // Trigger change detection + + expect(removeSpy).toHaveBeenCalled(); + + // Emit the same state again (should not trigger removal again) + statusUpdateStream.next({ + [operatorId]: { + operatorState: OperatorState.Uninitialized, + aggregatedOutputRowCount: 0, + aggregatedInputRowCount: 0, + }, + }); + + expect(removeSpy).toHaveBeenCalledTimes(1); // No additional call + }); + + it("should call doModifyBreakpoint on left click", () => { + // Simulate a left click on line 1 + component["onMouseLeftClick"](1); + + // Verify that the mock service was called with the correct arguments + expect(mockUdfDebugService.doModifyBreakpoint).toHaveBeenCalledWith(operatorId, 1); + }); + + it("should set breakpoint condition input on right click", () => { + // Mock a valid decoration map + component.monacoBreakpoint = { + lineNumberAndDecorationIdMap: new Map([ + [1, "breakpoint1"], + [2, "breakpoint2"], + ]), + } as any; + + // Simulate a right click on line 1, it should switch to 1 + component["onMouseRightClick"](1); + expect(component.breakpointConditionLine).toBe(1); + + // Simulate a right click on line 3, which does not have a breakpoint. no changes should occur + component["onMouseRightClick"](3); + expect(component.breakpointConditionLine).toBe(1); + + // Simulate a right click on line 2, it should switch to 2 + component["onMouseRightClick"](2); + expect(component.breakpointConditionLine).toBe(2); + + // Simulate a right click on line 1, it should switch to 1 + component["onMouseRightClick"](1); + expect(component.breakpointConditionLine).toBe(1); + }); + + it("should reset the breakpoint condition input when closed", () => { + // Set a condition line and close it + component.breakpointConditionLine = 1; + component.closeBreakpointConditionInput(); + + expect(component.breakpointConditionLine).toBeUndefined(); + }); +}); diff --git a/core/gui/src/app/workspace/component/code-editor-dialog/code-debugger.component.ts b/core/gui/src/app/workspace/component/code-editor-dialog/code-debugger.component.ts new file mode 100644 index 00000000000..0cc4c14ed86 --- /dev/null +++ b/core/gui/src/app/workspace/component/code-editor-dialog/code-debugger.component.ts @@ -0,0 +1,230 @@ +import { AfterViewInit, Component, Input, ViewChild } from "@angular/core"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; +import { SafeStyle } from "@angular/platform-browser"; +import "@codingame/monaco-vscode-python-default-extension"; +import "@codingame/monaco-vscode-r-default-extension"; +import "@codingame/monaco-vscode-java-default-extension"; +import { isDefined } from "../../../common/util/predicate"; +import { editor } from "monaco-editor/esm/vs/editor/editor.api.js"; +import { + EditorMouseEvent, + EditorMouseTarget, + ModelDecorationOptions, + MonacoEditor, + Range, +} from "monaco-breakpoints/dist/types"; +import { MonacoBreakpoint } from "monaco-breakpoints"; +import { UdfDebugService } from "../../service/operator-debug/udf-debug.service"; +import { BreakpointConditionInputComponent } from "./breakpoint-condition-input/breakpoint-condition-input.component"; +import { WorkflowStatusService } from "../../service/workflow-status/workflow-status.service"; +import { distinctUntilChanged, map } from "rxjs/operators"; +import { OperatorState } from "../../types/execute-workflow.interface"; +import MouseTargetType = editor.MouseTargetType; + +/** + * This component is the main component for the code debugger. + */ +@UntilDestroy() +@Component({ + selector: "texera-code-debugger", + templateUrl: "code-debugger.component.html", +}) +export class CodeDebuggerComponent implements AfterViewInit, SafeStyle { + @Input() monacoEditor!: MonacoEditor; + @Input() currentOperatorId!: string; + @ViewChild(BreakpointConditionInputComponent) breakpointConditionInput!: BreakpointConditionInputComponent; + + public monacoBreakpoint: MonacoBreakpoint | undefined = undefined; + public breakpointConditionLine: number | undefined = undefined; + + constructor( + private udfDebugService: UdfDebugService, + private workflowStatusService: WorkflowStatusService + ) {} + + ngAfterViewInit() { + this.registerStatusChangeHandler(); + this.registerBreakpointRenderingHandler(); + } + + setupMonacoBreakpointMethods(editor: MonacoEditor) { + // mimic the enum in monaco-breakpoints + enum BreakpointEnum { + Exist, + } + + this.monacoBreakpoint = new MonacoBreakpoint({ + editor, + hoverMessage: { + added: { + value: "Click to remove the breakpoint.", + }, + unAdded: { + value: "Click to add a breakpoint at this line.", + }, + }, + }); + // override the default createBreakpointDecoration so that it considers + // 1) hovering breakpoints; + // 2) exist breakpoints; + // 3) conditional breakpoints. (conditional breakpoints are also exist breakpoints) + this.monacoBreakpoint["createBreakpointDecoration"] = ( + range: Range, + breakpointEnum: BreakpointEnum + ): { range: Range; options: ModelDecorationOptions } => { + const condition = this.udfDebugService.getCondition(this.currentOperatorId, range.startLineNumber); + + const isConditional = Boolean(condition?.trim()); + const exists = breakpointEnum === BreakpointEnum.Exist; + + const glyphMarginClassName = exists + ? isConditional + ? "monaco-conditional-breakpoint" + : "monaco-breakpoint" + : "monaco-hover-breakpoint"; + + return { range, options: { glyphMarginClassName } }; + }; + + // override the default mouseDownDisposable to handle + // 1) left click to add/remove breakpoints; + // 2) right click to open breakpoint condition input. + this.monacoBreakpoint["mouseDownDisposable"]?.dispose(); + this.monacoBreakpoint["mouseDownDisposable"] = editor.onMouseDown((evt: EditorMouseEvent) => { + const { type, detail, position } = { ...(evt.target as EditorMouseTarget) }; + const model = editor.getModel()!; + if (model && type === MouseTargetType.GUTTER_GLYPH_MARGIN) { + if (detail.isAfterLines) { + return; + } + if (evt.event.leftButton) { + this.onMouseLeftClick(position.lineNumber); + } else { + this.onMouseRightClick(position.lineNumber); + } + } + }); + } + + removeMonacoBreakpointMethods() { + if (!isDefined(this.monacoBreakpoint)) { + return; + } + this.monacoBreakpoint["mouseDownDisposable"]?.dispose(); + this.monacoBreakpoint.dispose(); + } + + /** + * This function is called when the user left clicks on the gutter of the editor. + * It adds or removes a breakpoint on the line number that the user clicked on. + * @param lineNum the line number that the user clicked on + * @private + */ + private onMouseLeftClick(lineNum: number) { + // This indicates that the current position of the mouse is over the total number of lines in the editor + this.udfDebugService.doModifyBreakpoint(this.currentOperatorId, lineNum); + } + + /** + * This function is called when the user right clicks on the gutter of the editor. + * It opens the breakpoint condition input for the line number that the user clicked on. + * @param lineNum the line number that the user clicked on + * @private + */ + private onMouseRightClick(lineNum: number) { + if (!this.monacoBreakpoint!["lineNumberAndDecorationIdMap"].has(lineNum)) { + return; + } + + this.breakpointConditionLine = lineNum; + } + + closeBreakpointConditionInput() { + this.breakpointConditionLine = undefined; + } + + /** + * This function registers a handler that listens to the changes in the lineNumToBreakpointMapping. + * @private + */ + private registerBreakpointRenderingHandler() { + this.udfDebugService.getDebugState(this.currentOperatorId).observe(evt => { + evt.changes.keys.forEach((change, lineNum) => { + switch (change.action) { + case "add": + const addedValue = evt.target.get(lineNum)!; + if (isDefined(addedValue.breakpointId)) { + this.createBreakpointDecoration(Number(lineNum)); + } + break; + case "delete": + const deletedValue = change.oldValue; + if (isDefined(deletedValue.breakpointId)) { + this.removeBreakpointDecoration(Number(lineNum)); + } + break; + case "update": + const oldValue = change.oldValue; + const newValue = evt.target.get(lineNum)!; + if (newValue.hit) { + this.monacoBreakpoint?.setLineHighlight(Number(lineNum)); + } else { + this.monacoBreakpoint?.removeHighlight(); + } + if (oldValue.condition !== newValue.condition) { + // recreate the decoration with condition + this.removeBreakpointDecoration(Number(lineNum)); + this.createBreakpointDecoration(Number(lineNum)); + } + break; + } + }); + }); + } + + private createBreakpointDecoration(lineNum: number) { + this.monacoBreakpoint!["createSpecifyDecoration"]({ + startLineNumber: Number(lineNum), + endLineNumber: Number(lineNum), + }); + } + + private removeBreakpointDecoration(lineNum: number) { + const decorationId = this.monacoBreakpoint!["lineNumberAndDecorationIdMap"].get(lineNum); + this.monacoBreakpoint!["removeSpecifyDecoration"](decorationId, lineNum); + } + + rerenderExistingBreakpoints() { + this.udfDebugService.getDebugState(this.currentOperatorId).forEach(({ breakpointId }, lineNumStr) => { + if (!isDefined(breakpointId)) { + return; + } + this.createBreakpointDecoration(Number(lineNumStr)); + }); + } + + private registerStatusChangeHandler() { + this.workflowStatusService + .getStatusUpdateStream() + .pipe( + map( + event => + event[this.currentOperatorId]?.operatorState === OperatorState.Running || + event[this.currentOperatorId]?.operatorState === OperatorState.Paused + ), + distinctUntilChanged(), + untilDestroyed(this) + ) + .subscribe(enable => { + console.log("enable", enable); + // Only enable the breakpoint methods if the operator is running or paused + if (enable) { + this.setupMonacoBreakpointMethods(this.monacoEditor); + this.rerenderExistingBreakpoints(); + } else { + // for other states, remove the breakpoint methods + this.removeMonacoBreakpointMethods(); + } + }); + } +} diff --git a/core/gui/src/app/workspace/component/code-editor-dialog/code-editor.component.html b/core/gui/src/app/workspace/component/code-editor-dialog/code-editor.component.html index 63f615a31ac..31c8ac6f699 100644 --- a/core/gui/src/app/workspace/component/code-editor-dialog/code-editor.component.html +++ b/core/gui/src/app/workspace/component/code-editor-dialog/code-editor.component.html @@ -9,7 +9,7 @@ id="title" (focus)="onFocus()" cdkDragHandle> - {{languageTitle}} : {{ title }} + {{ languageTitle }} : {{ title }}
+ + + = new Subject(); - private currentOperatorId!: string; + public currentOperatorId!: string; + public title: string | undefined; public formControl!: FormControl; public componentRef: ComponentRef | undefined; @@ -70,6 +71,8 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy // For "Add All Type Annotation" to show the UI individually private userResponseSubject?: Subject; private isMultipleVariables: boolean = false; + public codeDebuggerComponent!: Type | null; + public editorToPass!: MonacoEditor; private generateLanguageTitle(language: string): string { return `${language.charAt(0).toUpperCase()}${language.slice(1)} UDF`; @@ -221,7 +224,7 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy filter(isDefined), untilDestroyed(this) ) - .subscribe((editor: IStandaloneCodeEditor) => { + .subscribe((editor: MonacoEditor) => { editor.updateOptions({ readOnly: this.formControl.disabled }); if (!this.code) { return; @@ -236,6 +239,7 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy this.workflowActionService.getTexeraGraph().getSharedModelAwareness() ); this.setupAIAssistantActions(editor); + this.initCodeDebuggerComponent(editor); }); } @@ -276,7 +280,12 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy this.editorWrapper.initAndStart(userConfig, this.editorElement.nativeElement); } - private setupAIAssistantActions(editor: IStandaloneCodeEditor) { + private initCodeDebuggerComponent(editor: MonacoEditor) { + this.codeDebuggerComponent = CodeDebuggerComponent; + this.editorToPass = editor; + } + + private setupAIAssistantActions(editor: MonacoEditor) { // Check if the AI provider is "openai" this.aiAssistantService .checkAIAssistantEnabled() @@ -290,7 +299,7 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy label: "Add Type Annotation", contextMenuGroupId: "1_modification", contextMenuOrder: 1.0, - run: (editor: monaco.editor.IStandaloneCodeEditor) => { + run: (editor: MonacoEditor) => { // User selected code (including range and content) const selection = editor.getSelection(); const model = editor.getModel(); @@ -314,7 +323,7 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy label: "Add All Type Annotations", contextMenuGroupId: "1_modification", contextMenuOrder: 1.1, - run: (editor: monaco.editor.IStandaloneCodeEditor) => { + run: (editor: MonacoEditor) => { const selection = editor.getSelection(); const model = editor.getModel(); if (!model || !selection) { @@ -402,7 +411,7 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy private handleTypeAnnotation( code: string, range: monaco.Range, - editor: monaco.editor.IStandaloneCodeEditor, + editor: MonacoEditor, lineNumber: number, allCode: string ): void { @@ -475,11 +484,7 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy } } - private insertTypeAnnotations( - editor: monaco.editor.IStandaloneCodeEditor, - selection: monaco.Selection, - annotations: string - ) { + private insertTypeAnnotations(editor: MonacoEditor, selection: monaco.Selection, annotations: string) { const endLineNumber = selection.endLineNumber; const endColumn = selection.endColumn; const insertPosition = new monaco.Position(endLineNumber, endColumn); diff --git a/core/gui/src/app/workspace/component/menu/menu.component.ts b/core/gui/src/app/workspace/component/menu/menu.component.ts index b671d40d954..4d370d312f8 100644 --- a/core/gui/src/app/workspace/component/menu/menu.component.ts +++ b/core/gui/src/app/workspace/component/menu/menu.component.ts @@ -31,6 +31,7 @@ import { NzModalService } from "ng-zorro-antd/modal"; import { ResultExportationComponent } from "../result-exportation/result-exportation.component"; import { ReportGenerationService } from "../../service/report-generation/report-generation.service"; import { ShareAccessComponent } from "src/app/dashboard/component/user/share-access/share-access.component"; +import { UdfDebugService } from "../../service/operator-debug/udf-debug.service"; /** * MenuComponent is the top level menu bar that shows * the Texera title and workflow execution button diff --git a/core/gui/src/app/workspace/component/result-panel/console-frame/console-frame.component.html b/core/gui/src/app/workspace/component/result-panel/console-frame/console-frame.component.html index 1ded24099e9..101dbac0f5a 100644 --- a/core/gui/src/app/workspace/component/result-panel/console-frame/console-frame.component.html +++ b/core/gui/src/app/workspace/component/result-panel/console-frame/console-frame.component.html @@ -81,7 +81,7 @@
{{ entry.title }}
-
{{entry.message}}
+
{{ entry.message }}
- {{entry.source}} + {{ entry.source }}
- {{(entry.timestamp.seconds * 1000 + entry.timestamp.nanos * 0.000001) | date : "M-d-yy, HH:mm:ss.SSS"}} + {{ (entry.timestamp.seconds * 1000 + entry.timestamp.nanos * 0.000001) | date : "M-d-yy, HH:mm:ss.SSS" }} - {{workerIdToAbbr(entry.workerId)}} + {{ workerIdToAbbr(entry.workerId) }} +
@@ -136,7 +136,7 @@
- + +
diff --git a/core/gui/src/app/workspace/component/result-panel/console-frame/console-frame.component.ts b/core/gui/src/app/workspace/component/result-panel/console-frame/console-frame.component.ts index c7bcf73dda0..54017726efa 100644 --- a/core/gui/src/app/workspace/component/result-panel/console-frame/console-frame.component.ts +++ b/core/gui/src/app/workspace/component/result-panel/console-frame/console-frame.component.ts @@ -9,6 +9,7 @@ import { presetPalettes } from "@ant-design/colors"; import { isDefined } from "../../../../common/util/predicate"; import { WorkflowWebsocketService } from "../../../service/workflow-websocket/workflow-websocket.service"; import { NotificationService } from "../../../../common/service/notification/notification.service"; +import { UdfDebugService } from "../../../service/operator-debug/udf-debug.service"; @UntilDestroy() @Component({ @@ -17,7 +18,7 @@ import { NotificationService } from "../../../../common/service/notification/not styleUrls: ["./console-frame.component.scss"], }) export class ConsoleFrameComponent implements OnInit, OnChanges { - @Input() operatorId?: string; + @Input() operatorId!: string; @Input() consoleInputEnabled?: boolean; @ViewChild(CdkVirtualScrollViewport) viewPort?: CdkVirtualScrollViewport; @ViewChild("consoleList", { read: ElementRef }) listElement?: ElementRef; @@ -47,7 +48,8 @@ export class ConsoleFrameComponent implements OnInit, OnChanges { private executeWorkflowService: ExecuteWorkflowService, private workflowConsoleService: WorkflowConsoleService, private workflowWebsocketService: WorkflowWebsocketService, - private notificationService: NotificationService + private notificationService: NotificationService, + private udfDebugService: UdfDebugService ) {} ngOnChanges(changes: SimpleChanges): void { @@ -160,6 +162,18 @@ export class ConsoleFrameComponent implements OnInit, OnChanges { } } + onClickStep(): void { + for (let worker of this.workerIds) { + this.udfDebugService.doStep(this.operatorId, worker); + } + } + + onClickContinue(): void { + for (let worker of this.workerIds) { + this.udfDebugService.doContinue(this.operatorId, worker); + } + } + getMessageLabel(message: ConsoleMessage): string { return this.labelMapping.get(message.msgType.name) ?? ""; } diff --git a/core/gui/src/app/workspace/service/operator-debug/udf-debug.service.spec.ts b/core/gui/src/app/workspace/service/operator-debug/udf-debug.service.spec.ts new file mode 100644 index 00000000000..2bbb4b637b5 --- /dev/null +++ b/core/gui/src/app/workspace/service/operator-debug/udf-debug.service.spec.ts @@ -0,0 +1,275 @@ +import { TestBed } from "@angular/core/testing"; +import { UdfDebugService } from "./udf-debug.service"; +import { WorkflowWebsocketService } from "../workflow-websocket/workflow-websocket.service"; +import { WorkflowActionService } from "../workflow-graph/model/workflow-action.service"; +import { WorkflowStatusService } from "../workflow-status/workflow-status.service"; +import { ExecuteWorkflowService } from "../execute-workflow/execute-workflow.service"; +import { Observable, Subject } from "rxjs"; +import { OperatorState, OperatorStatistics } from "../../types/execute-workflow.interface"; +import { WorkflowGraphReadonly } from "../workflow-graph/model/workflow-graph"; +import { mockPoint, mockPythonUDFPredicate } from "../workflow-graph/model/mock-workflow-data"; +import { OperatorMetadataService } from "../operator-metadata/operator-metadata.service"; +import { StubOperatorMetadataService } from "../operator-metadata/stub-operator-metadata.service"; +import * as Y from "yjs"; +import { ConsoleUpdateEvent } from "../../types/workflow-common.interface"; +import { TexeraWebsocketEvent } from "../../types/workflow-websocket.interface"; + +describe("UdfDebugServiceSpec", () => { + let service: UdfDebugService; + let workflowActionService: WorkflowActionService; + let mockWorkflowWebsocketService: jasmine.SpyObj; + let mockWorkflowStatusService: jasmine.SpyObj; + let mockExecuteWorkflowService: jasmine.SpyObj; + let statusUpdateStream: Subject>; + let consoleUpdateEventStream: Subject; + let texeraGraph: WorkflowGraphReadonly; + let stubWorker = "worker1"; + + beforeEach(() => { + // Create mock services + mockWorkflowWebsocketService = jasmine.createSpyObj("WorkflowWebsocketService", ["send", "subscribeToEvent"]); + mockWorkflowStatusService = jasmine.createSpyObj("WorkflowStatusService", ["getStatusUpdateStream"]); + mockExecuteWorkflowService = jasmine.createSpyObj("ExecuteWorkflowService", ["getWorkerIds"]); + + // Initialize the mock streams + statusUpdateStream = new Subject(); + consoleUpdateEventStream = new Subject(); + + // Set mock return values + mockWorkflowStatusService.getStatusUpdateStream.and.returnValue(statusUpdateStream.asObservable()); + mockWorkflowWebsocketService.subscribeToEvent.and.returnValue( + consoleUpdateEventStream.asObservable() as Observable + ); + mockExecuteWorkflowService.getWorkerIds.and.returnValue([stubWorker]); + + // Configure the TestBed + TestBed.configureTestingModule({ + providers: [ + UdfDebugService, + WorkflowActionService, + { + provide: OperatorMetadataService, + useClass: StubOperatorMetadataService, + }, + { provide: WorkflowWebsocketService, useValue: mockWorkflowWebsocketService }, + { provide: WorkflowStatusService, useValue: mockWorkflowStatusService }, + { provide: ExecuteWorkflowService, useValue: mockExecuteWorkflowService }, + ], + }); + + workflowActionService = TestBed.inject(WorkflowActionService); + texeraGraph = workflowActionService.getTexeraGraph(); + workflowActionService.addOperator(mockPythonUDFPredicate, mockPoint); + // Spy on the necessary methods + spyOn(texeraGraph, "createOperatorDebugState").and.callThrough(); + spyOn(texeraGraph, "getOperatorDebugState").and.callThrough(); + + service = TestBed.inject(UdfDebugService); + }); + + afterEach(() => { + // Clean up the streams after each test + statusUpdateStream.complete(); + consoleUpdateEventStream.complete(); + }); + + it("should initialize debug handlers on service creation", () => { + expect(texeraGraph.createOperatorDebugState).toHaveBeenCalledWith(mockPythonUDFPredicate.operatorID); + }); + + it("should retrieve the debug state of an operator", () => { + const state = service.getDebugState(mockPythonUDFPredicate.operatorID); + expect(state).toBeInstanceOf(Y.Map); + expect(state.size).toBe(0); // Initially empty + }); + + it("should get the condition of a breakpoint", () => { + const debugState = service.getDebugState(mockPythonUDFPredicate.operatorID); + debugState.set("1", { breakpointId: 1, condition: "x > 5", hit: false }); + + const condition = service.getCondition(mockPythonUDFPredicate.operatorID, 1); + expect(condition).toBe("x > 5"); + }); + + it("should return empty string if condition does not exist", () => { + const condition = service.getCondition(mockPythonUDFPredicate.operatorID, 2); + expect(condition).toBe(""); + }); + + it("should update the breakpoint condition if different", () => { + const debugState = service.getDebugState(mockPythonUDFPredicate.operatorID); + debugState.set("1", { breakpointId: 1, condition: "x > 5", hit: false }); + + service.doUpdateBreakpointCondition(mockPythonUDFPredicate.operatorID, 1, "x < 10"); + + expect(mockWorkflowWebsocketService.send).toHaveBeenCalledWith("DebugCommandRequest", { + operatorId: mockPythonUDFPredicate.operatorID, + workerId: stubWorker, + cmd: "condition 1 x < 10", + }); + + expect(debugState.get("1")?.condition).toBe("x < 10"); + }); + + it("should not update the breakpoint condition if it is the same", () => { + const debugState = service.getDebugState(mockPythonUDFPredicate.operatorID); + debugState.set("1", { breakpointId: 1, condition: "x > 5", hit: false }); + + service.doUpdateBreakpointCondition(mockPythonUDFPredicate.operatorID, 1, "x > 5"); + + expect(mockWorkflowWebsocketService.send).not.toHaveBeenCalled(); + }); + + it("should modify a breakpoint (remove existing)", () => { + const debugState = service.getDebugState(mockPythonUDFPredicate.operatorID); + debugState.set("1", { breakpointId: 1, condition: "", hit: false }); + + service.doModifyBreakpoint(mockPythonUDFPredicate.operatorID, 1); + + expect(mockWorkflowWebsocketService.send).toHaveBeenCalledWith("DebugCommandRequest", { + operatorId: mockPythonUDFPredicate.operatorID, + workerId: stubWorker, + cmd: "clear 1", + }); + + expect(debugState.has("1")).toBeTrue(); // The state is supposed to be cleared later by console update events. + }); + + it("should modify a breakpoint (add new)", () => { + const debugState = service.getDebugState(mockPythonUDFPredicate.operatorID); + + service.doModifyBreakpoint(mockPythonUDFPredicate.operatorID, 10); + + expect(mockWorkflowWebsocketService.send).toHaveBeenCalledWith("DebugCommandRequest", { + operatorId: mockPythonUDFPredicate.operatorID, + workerId: stubWorker, + cmd: "break 10", + }); + + // it should change the state yet + expect(debugState.has("10")).toBeFalse(); + }); + + it("should continue the workflow execution", () => { + service.doContinue(mockPythonUDFPredicate.operatorID, stubWorker); + + expect(mockWorkflowWebsocketService.send).toHaveBeenCalledWith("DebugCommandRequest", { + operatorId: mockPythonUDFPredicate.operatorID, + workerId: stubWorker, + cmd: "continue", + }); + }); + + it("should step through the workflow execution", () => { + service.doStep(mockPythonUDFPredicate.operatorID, stubWorker); + + expect(mockWorkflowWebsocketService.send).toHaveBeenCalledWith("DebugCommandRequest", { + operatorId: mockPythonUDFPredicate.operatorID, + workerId: stubWorker, + cmd: "next", + }); + }); + + it("should clear the debug state on state change to Uninitialized", () => { + const debugState = service.getDebugState(mockPythonUDFPredicate.operatorID); + const operatorId = mockPythonUDFPredicate.operatorID; + debugState.set(operatorId, { breakpointId: 1, condition: "x > 5", hit: false }); + statusUpdateStream.next({ + [operatorId]: { + operatorState: OperatorState.Uninitialized, + aggregatedInputRowCount: 0, + aggregatedOutputRowCount: 0, + }, + }); + + expect(debugState.size).toBe(0); + }); + + it("should handle console update events (breakpoint creation)", () => { + const message: ConsoleUpdateEvent = { + operatorId: mockPythonUDFPredicate.operatorID, + messages: [ + { + workerId: stubWorker, + timestamp: { nanos: 0, seconds: 0 }, + title: "Breakpoint 1 at /path/to/file.py:10", + source: "(Pdb)", + msgType: { name: "DEBUGGER" }, + message: "", + }, + ], + }; + + consoleUpdateEventStream.next(message); + + const debugState = service.getDebugState(mockPythonUDFPredicate.operatorID); + expect(debugState.get("10")).toEqual({ breakpointId: 1, condition: "", hit: false }); + }); + + it("should handle console update events (breakpoint deletion)", () => { + const debugState = service.getDebugState(mockPythonUDFPredicate.operatorID); + debugState.set("10", { breakpointId: 1, condition: "", hit: false }); + + const message = { + operatorId: mockPythonUDFPredicate.operatorID, + messages: [ + { + workerId: stubWorker, + timestamp: { nanos: 0, seconds: 0 }, + title: "Deleted breakpoint 1 at /path/to/file.py:10", + source: "(Pdb)", + msgType: { name: "DEBUGGER" }, + message: "", + }, + ], + }; + + consoleUpdateEventStream.next(message); + + expect(debugState.has("10")).toBeFalse(); + }); + + it("should handle console update events (stepping message)", () => { + spyOn(service as any, "markBreakpointAsHit").and.callThrough(); + + const message = { + operatorId: mockPythonUDFPredicate.operatorID, + messages: [ + { + workerId: stubWorker, + timestamp: { nanos: 0, seconds: 0 }, + title: "> /path/to/file.py(10)()", + source: "(Pdb)", + msgType: { name: "DEBUGGER" }, + message: "", + }, + ], + }; + + consoleUpdateEventStream.next(message); + + expect((service as UdfDebugService)["markBreakpointAsHit"]).toHaveBeenCalledWith( + mockPythonUDFPredicate.operatorID, + 10 + ); + }); + + it("should mark a breakpoint as hit", () => { + const debugState = service.getDebugState(mockPythonUDFPredicate.operatorID); + + service["markBreakpointAsHit"](mockPythonUDFPredicate.operatorID, 10); + + expect(debugState.get("10")).toEqual({ breakpointId: undefined, condition: "", hit: true }); + }); + + it("should mark continue by resetting hit statuses and removing temporary breakpoints", () => { + const debugState = service.getDebugState(mockPythonUDFPredicate.operatorID); + debugState.set("1", { breakpointId: 1, condition: "x > 5", hit: false }); + debugState.set("2", { breakpointId: undefined, condition: "", hit: true }); // Temporary breakpoint + + service["markContinue"](mockPythonUDFPredicate.operatorID); + + expect(debugState.get("1")).toEqual({ breakpointId: 1, condition: "x > 5", hit: false }); + expect(debugState.has("2")).toBeFalse(); + }); +}); diff --git a/core/gui/src/app/workspace/service/operator-debug/udf-debug.service.ts b/core/gui/src/app/workspace/service/operator-debug/udf-debug.service.ts new file mode 100644 index 00000000000..18adf76aa6f --- /dev/null +++ b/core/gui/src/app/workspace/service/operator-debug/udf-debug.service.ts @@ -0,0 +1,257 @@ +import { Injectable } from "@angular/core"; +import { WorkflowWebsocketService } from "../workflow-websocket/workflow-websocket.service"; +import { OperatorState } from "../../types/execute-workflow.interface"; +import { WorkflowActionService } from "../workflow-graph/model/workflow-action.service"; +import { isDefined } from "../../../common/util/predicate"; +import { WorkflowStatusService } from "../workflow-status/workflow-status.service"; +import { ExecuteWorkflowService } from "../execute-workflow/execute-workflow.service"; +import { filter, map, switchMap } from "rxjs/operators"; + +/** + * This service provides functionalities for debugging UDF operators. + */ +@Injectable({ + providedIn: "root", +}) +export class UdfDebugService { + constructor( + private workflowWebsocketService: WorkflowWebsocketService, + private workflowActionService: WorkflowActionService, + private workflowStatusService: WorkflowStatusService, + private executeWorkflowService: ExecuteWorkflowService + ) { + // Initializes debug handlers for all operators in the workflow graph. + const graph = this.workflowActionService.getTexeraGraph(); + graph.getAllOperators().forEach(op => { + graph.createOperatorDebugState(op.operatorID); + this.registerOperatorStateChangeHandler(op.operatorID); + this.registerConsoleUpdateHandler(op.operatorID); + }); + } + + /** + * Retrieves the debug state for a specific operator. + * + * @param operatorId - The unique ID of the operator. + * @returns A Y.Map containing the operator's debug state. + */ + getDebugState(operatorId: string) { + return this.workflowActionService.getTexeraGraph().getOperatorDebugState(operatorId); + } + + /** + * Gets the condition of a breakpoint for a specific line. + * + * @param operatorId - The unique ID of the operator. + * @param lineNumber - The line number where the breakpoint is set. + * @returns The condition string for the breakpoint. + */ + getCondition(operatorId: string, lineNumber: number): string { + const line = String(lineNumber); + const debugState = this.getDebugState(operatorId); + return debugState.has(line) ? debugState.get(line)!.condition : ""; + } + + /** + * Updates the condition of a breakpoint if it differs from the existing one. + * + * @param operatorId - The unique ID of the operator. + * @param lineNumber - The line number where the breakpoint is set. + * @param condition - The new condition to be applied to the breakpoint. + */ + doUpdateBreakpointCondition(operatorId: string, lineNumber: number, condition: string) { + if (condition === this.getCondition(operatorId, lineNumber)) return; + + const workerIds = this.executeWorkflowService.getWorkerIds(operatorId); + const debugState = this.getDebugState(operatorId); + const breakpointInfo = debugState.get(String(lineNumber)); + + if (isDefined(breakpointInfo)) { + workerIds.forEach(workerId => { + this.workflowWebsocketService.send("DebugCommandRequest", { + operatorId, + workerId, + cmd: `condition ${breakpointInfo.breakpointId} ${condition}`, + }); + }); + debugState.set(String(lineNumber), { ...breakpointInfo, condition }); + } + } + + /** + * Adds or removes a breakpoint based on its existence. + * + * @param operatorId - The unique ID of the operator. + * @param lineNumber - The line number to add or remove the breakpoint from. + */ + doModifyBreakpoint(operatorId: string, lineNumber: number) { + const workerIds = this.executeWorkflowService.getWorkerIds(operatorId); + const debugState = this.getDebugState(operatorId); + const cmd = debugState.has(String(lineNumber)) ? "clear" : "break"; + const breakpointId = debugState.get(String(lineNumber))?.breakpointId || ""; + + workerIds.forEach(workerId => { + this.workflowWebsocketService.send("DebugCommandRequest", { + operatorId, + workerId, + cmd: `${cmd} ${cmd === "clear" ? breakpointId : lineNumber}`, + }); + }); + } + + /** + * Continues the execution by resetting the temporary breakpoints. + * + * @param operatorId - The unique ID of the operator. + * @param workerId - The ID of the worker to continue execution on. + */ + doContinue(operatorId: string, workerId: string) { + this.markContinue(operatorId); + this.workflowWebsocketService.send("DebugCommandRequest", { + operatorId, + workerId, + cmd: "continue", + }); + } + + /** + * Steps through the execution. + * + * @param operatorId - The unique ID of the operator. + * @param workerId - The ID of the worker to step execution on. + */ + doStep(operatorId: string, workerId: string) { + this.markContinue(operatorId); + this.workflowWebsocketService.send("DebugCommandRequest", { + operatorId, + workerId, + cmd: "next", + }); + } + + /** + * Registers a handler for state changes of an operator. + * + * @param operatorId - The unique ID of the operator. + */ + private registerOperatorStateChangeHandler(operatorId: string) { + this.workflowStatusService + .getStatusUpdateStream() + .pipe(filter(event => event[operatorId]?.operatorState === OperatorState.Uninitialized)) + .subscribe(() => this.getDebugState(operatorId).clear()); + } + + /** + * Registers console update handlers for an operator. + * + * @param operatorId - The unique ID of the operator. + */ + private registerConsoleUpdateHandler(operatorId: string) { + const debugMessageStream = this.workflowWebsocketService.subscribeToEvent("ConsoleUpdateEvent").pipe( + filter(evt => evt.operatorId === operatorId && evt.messages.length > 0), + switchMap(evt => evt.messages), + filter(msg => msg.source === "(Pdb)" && msg.msgType.name === "DEBUGGER") + ); + + // Handle stepping message. + // Example: + // > /path/to/file.py(10)() + debugMessageStream + .pipe( + filter(msg => msg.title.startsWith(">")), + map(msg => this.extractInfo(msg.title)) + ) + .subscribe(({ lineNum }) => { + if (!isDefined(lineNum)) return; + this.markBreakpointAsHit(operatorId, lineNum); + }); + + // Handle breakpoint creation message. + // Example: + // Breakpoint 1 at /path/to/file.py:10 + debugMessageStream + .pipe( + filter(msg => msg.title.startsWith("Breakpoint")), + map(msg => this.extractInfo(msg.title)) + ) + .subscribe(({ breakpointId, lineNum }) => { + if (isDefined(breakpointId) && isDefined(lineNum)) { + this.getDebugState(operatorId).set(String(lineNum), { + breakpointId, + condition: "", + hit: false, + }); + } + }); + + // Handle breakpoint deletion message. + // Example: + // Deleted breakpoint 1 at /path/to/file.py:10 + debugMessageStream + .pipe( + filter(msg => msg.title.startsWith("Deleted")), + map(msg => this.extractInfo(msg.title)) + ) + .subscribe(({ lineNum }) => { + if (!isDefined(lineNum)) { + return; + } + const debugState = this.getDebugState(operatorId); + if (!debugState.has(String(lineNum))) { + return; + } + const breakpointInfo = debugState.get(String(lineNum))!; + debugState.delete(String(lineNum)); + + // if the breakpoint was hit, we need to keep it in the debug state + if (breakpointInfo.hit) { + debugState.set(String(lineNum), { ...breakpointInfo, breakpointId: undefined }); + } + }); + } + + /** + * Marks a breakpoint as hit, creating a temporary one if needed. + * + * @param operatorId - The unique ID of the operator. + * @param lineNum - The line number of the breakpoint to mark as hit. + */ + private markBreakpointAsHit(operatorId: string, lineNum: number) { + const line = String(lineNum); + const debugState = this.getDebugState(operatorId); + if (!debugState.has(line)) { + debugState.set(line, { breakpointId: undefined, condition: "", hit: false }); + } + const breakpoint = debugState.get(line)!; + debugState.set(line, { ...breakpoint, hit: true }); + } + + /** + * Resets hit status and removes temporary breakpoints. + * + * @param operatorId - The unique ID of the operator. + */ + private markContinue(operatorId: string) { + const debugState = this.getDebugState(operatorId); + debugState.forEach((value, key) => { + if (value.hit) debugState.set(key, { ...value, hit: false }); + if (!value.breakpointId) debugState.delete(key); + }); + } + + /** + * Extracts breakpoint information from a message. + * + * @param message - The message string containing breakpoint information. + * @returns An object containing breakpointId and lineNum. + */ + private extractInfo(message: string): { breakpointId?: number; lineNum?: number } { + const match = message.match(/(?:Breakpoint|Deleted breakpoint) (\d+) at .+:(\d+)/); + if (match) return { breakpointId: parseInt(match[1], 10), lineNum: parseInt(match[2], 10) }; + + const lineMatch = message.match(/\.py\((\d+)\)|:(\d+)/); + if (lineMatch) return { lineNum: parseInt(lineMatch[1] || lineMatch[2], 10) }; + + return {}; + } +} diff --git a/core/gui/src/app/workspace/service/operator-metadata/mock-operator-metadata.data.ts b/core/gui/src/app/workspace/service/operator-metadata/mock-operator-metadata.data.ts index 3d5e03e16bb..58d4527bb9c 100644 --- a/core/gui/src/app/workspace/service/operator-metadata/mock-operator-metadata.data.ts +++ b/core/gui/src/app/workspace/service/operator-metadata/mock-operator-metadata.data.ts @@ -251,12 +251,28 @@ export const mockUnionSchema: OperatorSchema = { operatorVersion: "union1", }; +export const mockPythonUDFSchema: OperatorSchema = { + operatorType: "PythonUDF", + additionalMetadata: { + userFriendlyName: "Python UDF", + operatorDescription: "custom operator in Java", + operatorGroupName: "UDF", + inputPorts: [{}], + outputPorts: [{}], + }, + jsonSchema: { + properties: {}, + type: "object", + }, + operatorVersion: "p1", +}; + export const mockJavaUDFSchema: OperatorSchema = { operatorType: "JavaUDF", additionalMetadata: { userFriendlyName: "Java UDF", operatorDescription: "custom operator in Java", - operatorGroupName: "Analysis", + operatorGroupName: "UDF", inputPorts: [{}], outputPorts: [{}], }, @@ -278,6 +294,7 @@ export const mockOperatorSchemaList: ReadonlyArray = [ mockMultiInputOutputSchema, mockPresetEnabledSchema, mockUnionSchema, + mockPythonUDFSchema, mockJavaUDFSchema, ]; diff --git a/core/gui/src/app/workspace/service/workflow-graph/model/mock-workflow-data.ts b/core/gui/src/app/workspace/service/workflow-graph/model/mock-workflow-data.ts index c80145a6779..805a73595bb 100644 --- a/core/gui/src/app/workspace/service/workflow-graph/model/mock-workflow-data.ts +++ b/core/gui/src/app/workspace/service/workflow-graph/model/mock-workflow-data.ts @@ -92,6 +92,17 @@ export const mockJavaUDFPredicate: OperatorPredicate = { isDisabled: false, }; +export const mockPythonUDFPredicate: OperatorPredicate = { + operatorID: "7", + operatorType: "PythonUDF", + operatorVersion: "p1", + operatorProperties: {}, + inputPorts: [{ portID: "input-0" }], + outputPorts: [{ portID: "output-0" }], + showAdvanced: false, + isDisabled: false, +}; + export const mockScanResultLink: OperatorLink = { linkID: "link-1", source: { diff --git a/core/gui/src/app/workspace/service/workflow-graph/model/shared-model.ts b/core/gui/src/app/workspace/service/workflow-graph/model/shared-model.ts index d8bc204a977..323849e13dc 100644 --- a/core/gui/src/app/workspace/service/workflow-graph/model/shared-model.ts +++ b/core/gui/src/app/workspace/service/workflow-graph/model/shared-model.ts @@ -1,8 +1,14 @@ import * as Y from "yjs"; import { WebsocketProvider } from "y-websocket"; import { Awareness } from "y-protocols/awareness"; -import { CommentBox, OperatorLink, OperatorPredicate, Point } from "../../../types/workflow-common.interface"; -import { User, CoeditorState } from "../../../../common/type/user"; +import { + CommentBox, + OperatorLink, + OperatorPredicate, + Point, + BreakpointInfo, +} from "../../../types/workflow-common.interface"; +import { CoeditorState, User } from "../../../../common/type/user"; import { getWebsocketUrl } from "../../../../common/util/url"; import { v4 as uuid } from "uuid"; import { YType } from "../../../types/shared-editing.interface"; @@ -20,6 +26,7 @@ export class SharedModel { public commentBoxMap: Y.Map>; public operatorLinkMap: Y.Map; public elementPositionMap: Y.Map; + public debugState: Y.Map>; public undoManager: Y.UndoManager; public clientId: string; @@ -36,6 +43,7 @@ export class SharedModel { public user?: User ) { // Initialize Y-structures. + this.debugState = this.yDoc.getMap("debugActions"); this.operatorIDMap = this.yDoc.getMap("operatorIDMap"); this.commentBoxMap = this.yDoc.getMap("commentBoxMap"); this.operatorLinkMap = this.yDoc.getMap("operatorLinkMap"); diff --git a/core/gui/src/app/workspace/service/workflow-graph/model/workflow-graph.ts b/core/gui/src/app/workspace/service/workflow-graph/model/workflow-graph.ts index c4119db4fc7..93168cda66a 100644 --- a/core/gui/src/app/workspace/service/workflow-graph/model/workflow-graph.ts +++ b/core/gui/src/app/workspace/service/workflow-graph/model/workflow-graph.ts @@ -8,6 +8,7 @@ import { PartitionInfo, PortDescription, PortProperty, + BreakpointInfo, } from "../../../types/workflow-common.interface"; import { isEqual } from "lodash-es"; import { SharedModel } from "./shared-model"; @@ -15,6 +16,7 @@ import { CoeditorState, User } from "../../../../common/type/user"; import { createYTypeFromObject, updateYTypeFromObject, YType } from "../../../types/shared-editing.interface"; import { Awareness } from "y-protocols/awareness"; import * as Y from "yjs"; +import { isDefined } from "../../../../common/util/predicate"; // define the restricted methods that could change the graph type restrictedMethods = @@ -586,6 +588,20 @@ export class WorkflowGraph { return commentBox.toJSON(); } + public createOperatorDebugState(operatorId: string) { + if (this.sharedModel.debugState.has(operatorId)) { + return; + } + this.sharedModel.debugState.set(operatorId, new Y.Map()); + } + + public getOperatorDebugState(operatorId: string): Y.Map { + if (!this.sharedModel.debugState.has(operatorId)) { + throw new Error(`operator ${operatorId} does not have a debug state`); + } + return this.sharedModel.debugState.get(operatorId)!; + } + /** * Returns an array of all operators in the graph. */ diff --git a/core/gui/src/app/workspace/types/workflow-common.interface.ts b/core/gui/src/app/workspace/types/workflow-common.interface.ts index 69ef962ea01..7d1a0a8867f 100644 --- a/core/gui/src/app/workspace/types/workflow-common.interface.ts +++ b/core/gui/src/app/workspace/types/workflow-common.interface.ts @@ -101,3 +101,9 @@ export type ConsoleUpdateEvent = Readonly<{ operatorId: string; messages: ReadonlyArray; }>; + +export type BreakpointInfo = Readonly<{ + breakpointId: number | undefined; + condition: string; + hit: boolean; +}>; diff --git a/core/gui/yarn.lock b/core/gui/yarn.lock index f12eeef7dda..3a759635a02 100644 --- a/core/gui/yarn.lock +++ b/core/gui/yarn.lock @@ -5563,13 +5563,13 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.10.0": - version: 8.10.0 - resolution: "@typescript-eslint/scope-manager@npm:8.10.0" +"@typescript-eslint/scope-manager@npm:8.11.0": + version: 8.11.0 + resolution: "@typescript-eslint/scope-manager@npm:8.11.0" dependencies: - "@typescript-eslint/types": "npm:8.10.0" - "@typescript-eslint/visitor-keys": "npm:8.10.0" - checksum: 10c0/b8bb8635c4d6c00a3578d6265e3ee0f5d96d0c9dee534ed588aa411c3f4497fd71cce730c3ae7571e52453d955b191bc9edcc47c9af21a20c90e9a20f2371108 + "@typescript-eslint/types": "npm:8.11.0" + "@typescript-eslint/visitor-keys": "npm:8.11.0" + checksum: 10c0/0910da62d8ae261711dd9f89d5c7d8e96ff13c50054436256e5a661309229cb49e3b8189c9468d36b6c4d3f7cddd121519ea78f9b18c9b869a808834b079b2ea languageName: node linkType: hard @@ -5608,17 +5608,17 @@ __metadata: linkType: hard "@typescript-eslint/type-utils@npm:^8.0.0": - version: 8.10.0 - resolution: "@typescript-eslint/type-utils@npm:8.10.0" + version: 8.11.0 + resolution: "@typescript-eslint/type-utils@npm:8.11.0" dependencies: - "@typescript-eslint/typescript-estree": "npm:8.10.0" - "@typescript-eslint/utils": "npm:8.10.0" + "@typescript-eslint/typescript-estree": "npm:8.11.0" + "@typescript-eslint/utils": "npm:8.11.0" debug: "npm:^4.3.4" ts-api-utils: "npm:^1.3.0" peerDependenciesMeta: typescript: optional: true - checksum: 10c0/1af8fce8394279e6ac7bcef449a132072ee36e374c8d557564246ffe7150230844901ca0305e29525bf37c87010e03bf8bedec76fccbfe1e41931cb4f274e208 + checksum: 10c0/b69e31c1599ceeb20c29052a4ddb33a554174a3a4c55ee37d90c9b8250af6ef978a0b9ddbeefef4e83d62c4caea1bfa2d8088527f397bde69fb4ab9b360d794a languageName: node linkType: hard @@ -5643,10 +5643,10 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/types@npm:8.10.0": - version: 8.10.0 - resolution: "@typescript-eslint/types@npm:8.10.0" - checksum: 10c0/f27dd43c8383e02e914a254257627e393dfc0f08b0f74a253c106813ae361f090271b2f3f2ef588fa3ca1329897d873da595bb5641fe8e3091b25eddca24b5d2 +"@typescript-eslint/types@npm:8.11.0": + version: 8.11.0 + resolution: "@typescript-eslint/types@npm:8.11.0" + checksum: 10c0/5ccdd3eeee077a6fc8e7f4bc0e0cbc9327b1205a845253ec5c0c6c49ff915e853161df00c24a0ffb4b8ec745d3f153dd0e066400a021c844c026e31121f46699 languageName: node linkType: hard @@ -5706,12 +5706,12 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.10.0": - version: 8.10.0 - resolution: "@typescript-eslint/typescript-estree@npm:8.10.0" +"@typescript-eslint/typescript-estree@npm:8.11.0": + version: 8.11.0 + resolution: "@typescript-eslint/typescript-estree@npm:8.11.0" dependencies: - "@typescript-eslint/types": "npm:8.10.0" - "@typescript-eslint/visitor-keys": "npm:8.10.0" + "@typescript-eslint/types": "npm:8.11.0" + "@typescript-eslint/visitor-keys": "npm:8.11.0" debug: "npm:^4.3.4" fast-glob: "npm:^3.3.2" is-glob: "npm:^4.0.3" @@ -5721,7 +5721,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10c0/535a740fe25be0e28fe68c41e3264273d1e5169c9f938e08cc0e3415c357726f43efa44621960108c318fc3305c425d29f3223b6e731d44d67f84058a8947304 + checksum: 10c0/b629ad3cd32b005d5c1d67c36958a418f8672efebea869399834f4f201ebf90b942165eebb5c9d9799dcabdc2cc26e5fabb00629f76b158847f42e1a491a75a6 languageName: node linkType: hard @@ -5760,17 +5760,17 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.10.0": - version: 8.10.0 - resolution: "@typescript-eslint/utils@npm:8.10.0" +"@typescript-eslint/utils@npm:8.11.0": + version: 8.11.0 + resolution: "@typescript-eslint/utils@npm:8.11.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.4.0" - "@typescript-eslint/scope-manager": "npm:8.10.0" - "@typescript-eslint/types": "npm:8.10.0" - "@typescript-eslint/typescript-estree": "npm:8.10.0" + "@typescript-eslint/scope-manager": "npm:8.11.0" + "@typescript-eslint/types": "npm:8.11.0" + "@typescript-eslint/typescript-estree": "npm:8.11.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 - checksum: 10c0/a21a2933517176abd00fcd5d8d80023e35dc3d89d5746bbac43790b4e984ab1f371117db08048bce7f42d54c64f4e0e35161149f8f34fd25a27bff9d1110fd16 + checksum: 10c0/bb5bcc8d928a55b22298e76f834ea6a9fe125a9ffeb6ac23bee0258b3ed32f41e281888a3d0be226a05e1011bb3b70e42a71a40366acdefea6779131c46bc522 languageName: node linkType: hard @@ -5804,13 +5804,13 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.10.0": - version: 8.10.0 - resolution: "@typescript-eslint/visitor-keys@npm:8.10.0" +"@typescript-eslint/visitor-keys@npm:8.11.0": + version: 8.11.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.11.0" dependencies: - "@typescript-eslint/types": "npm:8.10.0" + "@typescript-eslint/types": "npm:8.11.0" eslint-visitor-keys: "npm:^3.4.3" - checksum: 10c0/14721c4ac939640d5fd1ee1b6eeb07604b11a6017e319e21dcc71e7aac2992341fc7ae1992d977bad4433b6a1d0d1c0c279e6927316b26245f6e333f922fa458 + checksum: 10c0/7a5a49609fdc47e114fe59eee56393c90b122ec8e9520f90b0c5e189635ae1ccfa8e00108f641342c2c8f4637fe9d40c77927cf7c8248a3a660812cb4b7d0c08 languageName: node linkType: hard @@ -7023,16 +7023,16 @@ __metadata: linkType: hard "browserslist@npm:^4.0.0, browserslist@npm:^4.14.5, browserslist@npm:^4.21.4, browserslist@npm:^4.21.5, browserslist@npm:^4.23.0, browserslist@npm:^4.23.3, browserslist@npm:^4.24.0": - version: 4.24.0 - resolution: "browserslist@npm:4.24.0" + version: 4.24.2 + resolution: "browserslist@npm:4.24.2" dependencies: - caniuse-lite: "npm:^1.0.30001663" - electron-to-chromium: "npm:^1.5.28" + caniuse-lite: "npm:^1.0.30001669" + electron-to-chromium: "npm:^1.5.41" node-releases: "npm:^2.0.18" - update-browserslist-db: "npm:^1.1.0" + update-browserslist-db: "npm:^1.1.1" bin: browserslist: cli.js - checksum: 10c0/95e76ad522753c4c470427f6e3c8a4bb5478ff448841e22b3d3e53f89ecaf17b6984666d6c7e715c370f1e7fa0cf684f42e34e554236a8b2fab38ea76b9e4c52 + checksum: 10c0/d747c9fb65ed7b4f1abcae4959405707ed9a7b835639f8a9ba0da2911995a6ab9b0648fd05baf2a4d4e3cf7f9fdbad56d3753f91881e365992c1d49c8d88ff7a languageName: node linkType: hard @@ -7247,7 +7247,7 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001464, caniuse-lite@npm:^1.0.30001646, caniuse-lite@npm:^1.0.30001663": +"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001464, caniuse-lite@npm:^1.0.30001646, caniuse-lite@npm:^1.0.30001669": version: 1.0.30001669 resolution: "caniuse-lite@npm:1.0.30001669" checksum: 10c0/f125f23440d3dbb6c25ffb8d55f4ce48af36a84d0932b152b3b74f143a4170cbe92e02b0a9676209c86609bf7bf34119ff10cc2bc7c1b7ea40e936cc16598408 @@ -9104,7 +9104,7 @@ __metadata: languageName: node linkType: hard -"electron-to-chromium@npm:^1.5.28": +"electron-to-chromium@npm:^1.5.41": version: 1.5.41 resolution: "electron-to-chromium@npm:1.5.41" checksum: 10c0/97b82383963029e6ed0bd7a71eb527f640c8cf658c9e43c776b0257b3c65e366590ac54135683a21e4474a156b8be78717d6e94d3c1def84b69f92bf48f2390f @@ -11122,6 +11122,7 @@ __metadata: karma-jasmine-html-reporter: "npm:1.7.0" lodash-es: "npm:4.17.21" marked: "npm:4.3.0" + monaco-breakpoints: "npm:0.2.0" monaco-editor: "npm:@codingame/monaco-vscode-editor-api@8.0.4" monaco-editor-wrapper: "npm:5.5.3" monaco-languageclient: "npm:8.8.3" @@ -13819,6 +13820,15 @@ __metadata: languageName: node linkType: hard +"monaco-breakpoints@npm:0.2.0": + version: 0.2.0 + resolution: "monaco-breakpoints@npm:0.2.0" + peerDependencies: + monaco-editor: ^0.39.0 + checksum: 10c0/4161b4e3c0afd8d1c68b880ad3e36c16acc1105f733e584c261c61d35a67274290a9db14dcd8e5c669e293aedd5f4f8c2525c7872ea98895e1cd15ef77852152 + languageName: node + linkType: hard + "monaco-editor-wrapper@npm:5.5.3": version: 5.5.3 resolution: "monaco-editor-wrapper@npm:5.5.3" @@ -18378,7 +18388,7 @@ __metadata: languageName: node linkType: hard -"update-browserslist-db@npm:^1.1.0": +"update-browserslist-db@npm:^1.1.1": version: 1.1.1 resolution: "update-browserslist-db@npm:1.1.1" dependencies: