Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion frontend/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import { DatePipe, registerLocaleData } from "@angular/common";
import { HTTP_INTERCEPTORS, HttpClientModule } from "@angular/common/http";
import en from "@angular/common/locales/en";
import { APP_INITIALIZER, CUSTOM_ELEMENTS_SCHEMA, NgModule } from "@angular/core";
import { APP_INITIALIZER, CUSTOM_ELEMENTS_SCHEMA, APP_BOOTSTRAP_LISTENER, NgModule } from "@angular/core";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { BrowserModule } from "@angular/platform-browser";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
Expand Down Expand Up @@ -191,6 +191,7 @@ import { NzCheckboxModule } from "ng-zorro-antd/checkbox";
import { RegistrationRequestModalComponent } from "./common/service/user/registration-request-modal/registration-request-modal.component";
import { UserComputingUnitComponent } from "./dashboard/component/user/user-computing-unit/user-computing-unit.component";
import { UserComputingUnitListItemComponent } from "./dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component";
import { JupyterPanelService } from "./workspace/service/jupyter-panel/jupyter-panel.service";

registerLocaleData(en);

Expand Down Expand Up @@ -404,6 +405,12 @@ registerLocaleData(en);
deps: [GuiConfigService],
multi: true,
},
{
provide: APP_BOOTSTRAP_LISTENER,
useFactory: (jupyterPanelService: JupyterPanelService) => () => jupyterPanelService.init(),
deps: [JupyterPanelService],
multi: true,
},
],
bootstrap: [AppComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { NzNoAnimationDirective } from "ng-zorro-antd/core/animation";
import { ContextMenuComponent } from "./context-menu/context-menu/context-menu.component";
import { NgIf } from "@angular/common";
import { AgentInteractionComponent } from "../agent/agent-interaction/agent-interaction.component";
import { JupyterPanelService } from "../../service/jupyter-panel/jupyter-panel.service";

// jointjs interactive options for enabling and disabling interactivity
// https://resources.jointjs.com/docs/jointjs/v3.2/joint.html#dia.Paper.prototype.options.interactive
Expand Down Expand Up @@ -128,7 +129,8 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
public nzContextMenu: NzContextMenuService,
private elementRef: ElementRef,
private config: GuiConfigService,
private agentService: AgentService
private agentService: AgentService,
private jupyterPanelService: JupyterPanelService
) {
this.wrapper = this.workflowActionService.getJointGraphWrapper();
}
Expand Down Expand Up @@ -603,12 +605,14 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
const elementID = event[0].model.id.toString();
const highlightedOperatorIDs = this.wrapper.getCurrentHighlightedOperatorIDs();
const highlightedCommentBoxIDs = this.wrapper.getCurrentHighlightedCommentBoxIDs();
this.jupyterPanelService.onWorkflowComponentClick(elementID); // Highlight corresponding Jupyter notebook cell
if (event[1].shiftKey) {
// if in multiselect toggle highlights on click
if (highlightedOperatorIDs.includes(elementID)) {
this.workflowActionService.unhighlightOperators(elementID);
} else if (this.workflowActionService.getTexeraGraph().hasOperator(elementID)) {
this.workflowActionService.highlightOperators(<boolean>event[1].shiftKey, elementID);
this.jupyterPanelService.onWorkflowComponentClick(elementID); // Highlight corresponding Jupyter notebook cell
}
if (highlightedCommentBoxIDs.includes(elementID)) {
this.wrapper.unhighlightCommentBoxes(elementID);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { TestBed } from "@angular/core/testing";
import { JupyterPanelService } from "./jupyter-panel.service";
import { WorkflowActionService } from "../workflow-graph/model/workflow-action.service";
import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
import { NotificationService } from "src/app/common/service/notification/notification.service";
import { NotebookMigrationService } from "../notebook-migration/notebook-migration.service";
import { GuiConfigService } from "src/app/common/service/gui-config.service";
import { of } from "rxjs";

describe("JupyterPanelService", () => {
let service: JupyterPanelService;
let httpMock: HttpTestingController;

let mockWorkflow: any;
let mockNotification: any;
let mockNotebook: any;
// Mutable so individual describe blocks can flip the flag mid-spec; the
// service stores a reference, so mutations are observed on the next read.
let mockGuiConfig: { env: { pythonNotebookMigrationEnabled: boolean } };

beforeEach(() => {
mockWorkflow = {
workflowMetaDataChanged: jasmine.createSpy().and.returnValue(of({ wid: 1 })),
getWorkflow: jasmine.createSpy().and.returnValue({ wid: 1 }),
getTexeraGraph: jasmine.createSpy().and.returnValue({
getAllLinks: () => [
{
linkID: "L1",
source: { operatorID: "A" },
target: { operatorID: "B" },
},
],
getAllOperators: () => [{ operatorID: "A" }, { operatorID: "B" }],
}),
highlightOperators: jasmine.createSpy(),
highlightLinks: jasmine.createSpy(),
unhighlightOperators: jasmine.createSpy(),
unhighlightLinks: jasmine.createSpy(),
};

mockNotification = {
warning: jasmine.createSpy(),
};

mockNotebook = {
hasMapping: jasmine.createSpy().and.returnValue(true),
getMapping: jasmine.createSpy().and.returnValue({
cell_to_operator: {
cell1: ["A", "B"],
},
operator_to_cell: {},
}),
deleteMapping: jasmine.createSpy(),
setMapping: jasmine.createSpy(),
getJupyterURL: jasmine.createSpy().and.resolveTo("http://jupyter"),
};

mockGuiConfig = { env: { pythonNotebookMigrationEnabled: true } };

TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
JupyterPanelService,
{ provide: WorkflowActionService, useValue: mockWorkflow },
{ provide: NotificationService, useValue: mockNotification },
{ provide: NotebookMigrationService, useValue: mockNotebook },
{ provide: GuiConfigService, useValue: mockGuiConfig },
],
});

service = TestBed.inject(JupyterPanelService);
httpMock = TestBed.inject(HttpTestingController);
});

afterEach(() => {
httpMock.verify();
});

// HTTP fetchNotebookAndMapping
it("should return 0 when exists=false", done => {
(service as any).fetchNotebookAndMapping(1, 1).subscribe((result: any) => {
expect(result).toBe(0);
done();
});

const req = httpMock.expectOne(r => r.url.includes("/notebook-migration/fetch-notebook-and-mapping"));

req.flush({ exists: false });
});

// iframe ref
it("should store iframe reference", () => {
const iframe = document.createElement("iframe");

service.setIframeRef(iframe);

expect((service as any).iframeRef).toBe(iframe);
});

// highlightFromCell
it("should highlight operators and links", () => {
(service as any).cellToHighlightMapping = {
cell1: {
components: ["op1", "op2"],
edges: ["link1"],
},
};

const method = (service as any).highlightFromCell.bind(service);

method("cell1");

expect(mockWorkflow.unhighlightOperators).toHaveBeenCalled();
expect(mockWorkflow.unhighlightLinks).toHaveBeenCalled();
expect(mockWorkflow.highlightOperators).toHaveBeenCalledWith(true, "op1", "op2");
expect(mockWorkflow.highlightLinks).toHaveBeenCalledWith(true, "link1");
});

// onWorkflowComponentClick
it("should postMessage when mapping exists", async () => {
const mockIframe = {
contentWindow: {
postMessage: jasmine.createSpy(),
},
} as any;

service.setIframeRef(mockIframe);
(mockNotebook as any).getMapping.and.returnValue({
cell_to_operator: {},
operator_to_cell: {
cell1: ["op1", "op2"],
},
});

await service.onWorkflowComponentClick("cell1");

expect(mockIframe.contentWindow.postMessage).toHaveBeenCalledWith(
{
action: "triggerCellClick",
operators: ["op1", "op2"],
},
"http://jupyter"
);
});

// Feature flag gate (defence in depth). With the flag off, init must not
// subscribe to workflow changes, and onWorkflowComponentClick must not
// postMessage to the iframe. The window message listener is installed in
// the constructor unconditionally, but handleNotebookMessage returns early
// on the flag check.
describe("when the feature flag is disabled", () => {
beforeEach(() => {
mockGuiConfig.env.pythonNotebookMigrationEnabled = false;
});

it("init does not subscribe to workflowMetaDataChanged", () => {
service.init();
expect(mockWorkflow.workflowMetaDataChanged).not.toHaveBeenCalled();
});

it("onWorkflowComponentClick does not postMessage to the iframe", async () => {
const mockIframe = {
contentWindow: { postMessage: jasmine.createSpy() },
} as any;
service.setIframeRef(mockIframe);
await service.onWorkflowComponentClick("cell1");
expect(mockIframe.contentWindow.postMessage).not.toHaveBeenCalled();
});
});
});
Loading
Loading