From 28f4338165225715f2f17b7b02cb92796e0e1ad3 Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Thu, 28 May 2026 10:50:32 -0700 Subject: [PATCH 01/21] added jupyter notebook panel iframe logic --- frontend/src/app/app.module.ts | 2 + .../jupyter-notebook-panel.component.html | 63 ++++++++ .../jupyter-notebook-panel.component.scss | 80 +++++++++ .../jupyter-notebook-panel.component.spec.ts | 152 ++++++++++++++++++ .../jupyter-notebook-panel.component.ts | 103 ++++++++++++ 5 files changed, 400 insertions(+) create mode 100644 frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.html create mode 100644 frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.scss create mode 100644 frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts create mode 100644 frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.ts diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 78fc75d7cbc..d02e2ac96c0 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -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 { JupyterNotebookPanelComponent } from "./workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component"; registerLocaleData(en); @@ -360,6 +361,7 @@ registerLocaleData(en); MarkdownDescriptionComponent, UserComputingUnitComponent, UserComputingUnitListItemComponent, + JupyterNotebookPanelComponent, ], providers: [ provideNzI18n(en_US), diff --git a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.html b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.html new file mode 100644 index 00000000000..546f4e43571 --- /dev/null +++ b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.html @@ -0,0 +1,63 @@ + + +
+
+ Jupyter Notebook +
+ + + + +
+
+ +
+ +
+
diff --git a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.scss b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.scss new file mode 100644 index 00000000000..767ffa33e7d --- /dev/null +++ b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.scss @@ -0,0 +1,80 @@ +/** + * 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. + */ + +.draggable-panel { + position: absolute; + top: 50%; + left: 50%; + margin-top: 200px; + width: 660px; + height: 400px; + background-color: #fff; + box-shadow: 0 1px 5px rgba(0, 0, 0, 0.3); + border: 1px solid #ccc; + border-radius: 5px; + z-index: 1000; + resize: both; + overflow: auto; +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + background-color: #547baa; + color: white; + padding: 5px 10px 5px 10px; + font-weight: bold; + text-align: center; + cursor: move; +} + +.panel-buttons { + display: flex; + align-items: center; + gap: 8px; +} + +.minimize-button, +.delete-button { + background: none; + border: none; + color: white; + font-size: 16px; + cursor: pointer; +} + +.minimize-button:hover { + color: royalblue; +} + +.close-button:hover { + color: royalblue; +} + +.iframe-container { + width: 100%; + height: calc(100% - 40px); /* Adjust height for the header */ +} + +.iframe-container iframe { + width: 100%; + height: 100%; + border: none; +} diff --git a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts new file mode 100644 index 00000000000..1a1551ae351 --- /dev/null +++ b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts @@ -0,0 +1,152 @@ +/** + * 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 { ComponentFixture, TestBed } from "@angular/core/testing"; +import { JupyterNotebookPanelComponent } from "./jupyter-notebook-panel.component"; +import { JupyterPanelService } from "../../service/jupyter-panel/jupyter-panel.service"; +import { NotebookMigrationService } from "../../service/notebook-migration/notebook-migration.service"; +import { BrowserModule } from "@angular/platform-browser"; +import { Subject } from "rxjs"; +import { ElementRef } from "@angular/core"; + +describe("JupyterNotebookPanelComponent", () => { + let component: JupyterNotebookPanelComponent; + let fixture: ComponentFixture; + + let mockJupyterPanelService: any; + let mockNotebookMigrationService: any; + + beforeEach(async () => { + mockJupyterPanelService = { + jupyterNotebookPanelVisible$: new Subject(), + setIframeRef: jasmine.createSpy("setIframeRef"), + closeJupyterNotebookPanel: jasmine.createSpy("closeJupyterNotebookPanel"), + minimizeJupyterNotebookPanel: jasmine.createSpy("minimizeJupyterNotebookPanel"), + }; + + mockNotebookMigrationService = { + getJupyterIframeURL: jasmine + .createSpy("getJupyterIframeURL") + .and.returnValue(Promise.resolve("http://localhost:8888")), + }; + + await TestBed.configureTestingModule({ + declarations: [JupyterNotebookPanelComponent], + imports: [BrowserModule], + providers: [ + { provide: JupyterPanelService, useValue: mockJupyterPanelService }, + { provide: NotebookMigrationService, useValue: mockNotebookMigrationService }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(JupyterNotebookPanelComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + spyOn(component, "checkIframeRef").and.stub(); + expect(component).toBeTruthy(); + }); + + it("should be hidden by default", () => { + spyOn(component, "checkIframeRef").and.stub(); + expect(component.isVisible).toBeFalse(); + }); + + it("should update visibility when service emits", async () => { + spyOn(component, "checkIframeRef").and.stub(); + mockJupyterPanelService.jupyterNotebookPanelVisible$.next(true); + + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.isVisible).toBeTrue(); + }); + + it("should fetch and sanitize URL when panel becomes visible", async () => { + spyOn(component, "checkIframeRef").and.stub(); + mockJupyterPanelService.jupyterNotebookPanelVisible$.next(true); + + await fixture.whenStable(); + fixture.detectChanges(); + + expect(mockNotebookMigrationService.getJupyterIframeURL).toHaveBeenCalled(); + expect(component.jupyterUrl.toString()).toContain("http://localhost:8888"); + }); + + it("should call setIframeRef when iframe exists and visible", done => { + spyOn(component, "checkIframeRef").and.stub(); + component.isVisible = true; + + const mockIframe = document.createElement("iframe"); + component.iframeRef = new ElementRef(mockIframe); + + component.checkIframeRef(); + + setTimeout(() => { + expect(mockJupyterPanelService.setIframeRef).toHaveBeenCalledWith(mockIframe); + done(); + }, 0); + }); + + it("should NOT call setIframeRef if not visible", done => { + spyOn(component, "checkIframeRef").and.stub(); + component.isVisible = false; + + const mockIframe = document.createElement("iframe"); + component.iframeRef = new ElementRef(mockIframe); + + component.checkIframeRef(); + + setTimeout(() => { + expect(mockJupyterPanelService.setIframeRef).not.toHaveBeenCalled(); + done(); + }, 0); + }); + + it("should close panel via service", () => { + spyOn(component, "checkIframeRef").and.stub(); + component.closePanel(); + expect(mockJupyterPanelService.closeJupyterNotebookPanel).toHaveBeenCalled(); + }); + + it("should minimize panel and update visibility", () => { + spyOn(component, "checkIframeRef").and.stub(); + component.isVisible = true; + + component.minimizePanel(); + + expect(component.isVisible).toBeFalse(); + expect(mockJupyterPanelService.minimizeJupyterNotebookPanel).toHaveBeenCalled(); + }); + + it("should clean up on destroy", () => { + spyOn(component, "checkIframeRef").and.stub(); + const nextSpy = spyOn(component["destroy$"], "next"); + const completeSpy = spyOn(component["destroy$"], "complete"); + + component.ngOnDestroy(); + + expect(nextSpy).toHaveBeenCalled(); + expect(completeSpy).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.ts b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.ts new file mode 100644 index 00000000000..bd96dfbe10a --- /dev/null +++ b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.ts @@ -0,0 +1,103 @@ +/** + * 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 { Component, ElementRef, OnDestroy, OnInit, ViewChild, AfterViewInit } from "@angular/core"; +import { JupyterPanelService } from "../../service/jupyter-panel/jupyter-panel.service"; +import { from, of, Subject } from "rxjs"; +import { switchMap, takeUntil } from "rxjs/operators"; +import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; +import { NotebookMigrationService } from "../../service/notebook-migration/notebook-migration.service"; +import { CommonModule } from "@angular/common"; +import { DragDropModule } from "@angular/cdk/drag-drop"; +import { NzButtonModule } from "ng-zorro-antd/button"; +import { NzIconModule } from "ng-zorro-antd/icon"; +import { NzPopconfirmModule } from "ng-zorro-antd/popconfirm"; + +@Component({ + selector: "texera-jupyter-notebook-panel", + templateUrl: "./jupyter-notebook-panel.component.html", + styleUrls: ["./jupyter-notebook-panel.component.scss"], + imports: [CommonModule, DragDropModule, NzButtonModule, NzIconModule, NzPopconfirmModule], +}) +export class JupyterNotebookPanelComponent implements OnInit, AfterViewInit, OnDestroy { + @ViewChild("iframeRef", { static: false }) iframeRef!: ElementRef; // Use static: false + + isVisible: boolean = false; // Initialize to false, meaning the panel is hidden by default + jupyterUrl: SafeResourceUrl = ""; // Store the notebook URL dynamically + private destroy$ = new Subject(); + + constructor( + private jupyterPanelService: JupyterPanelService, + private sanitizer: DomSanitizer, + private notebookMigrationService: NotebookMigrationService + ) {} + + ngOnInit(): void { + this.jupyterPanelService.jupyterNotebookPanelVisible$ + .pipe( + switchMap((visible: boolean) => { + this.isVisible = visible; + + if (!visible) { + return of(null); + } + + return from(this.notebookMigrationService.getJupyterIframeURL()); + }), + takeUntil(this.destroy$) + ) + .subscribe(url => { + if (url) { + this.jupyterUrl = this.sanitizer.bypassSecurityTrustResourceUrl(url); + this.checkIframeRef(); + } + }); + } + + ngAfterViewInit(): void { + // Ensure iframe is handled after it's available in the DOM + this.checkIframeRef(); + } + + checkIframeRef(): void { + setTimeout(() => { + if (this.isVisible && this.iframeRef?.nativeElement) { + this.jupyterPanelService.setIframeRef(this.iframeRef.nativeElement); + } else { + console.error("Jupyter Iframe reference not found."); + } + }, 0); // Small timeout to ensure DOM is updated + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); // Cleanup subscriptions to avoid memory leaks + } + + // Close the panel by invoking the service method + closePanel(): void { + this.jupyterPanelService.closeJupyterNotebookPanel(); + } + + // Minimize the jupyter notebook by invoking the service method + minimizePanel(): void { + this.isVisible = false; + this.jupyterPanelService.minimizeJupyterNotebookPanel(); + } +} From ffaa0076b71d90baddd117f06234af67d8608f95 Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Thu, 23 Jul 2026 14:58:16 -0700 Subject: [PATCH 02/21] convert jupyter notebook panel spec to vitest --- .../jupyter-notebook-panel.component.spec.ts | 52 ++++++++----------- 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts index 1a1551ae351..aa16feb8e20 100644 --- a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts +++ b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts @@ -35,15 +35,13 @@ describe("JupyterNotebookPanelComponent", () => { beforeEach(async () => { mockJupyterPanelService = { jupyterNotebookPanelVisible$: new Subject(), - setIframeRef: jasmine.createSpy("setIframeRef"), - closeJupyterNotebookPanel: jasmine.createSpy("closeJupyterNotebookPanel"), - minimizeJupyterNotebookPanel: jasmine.createSpy("minimizeJupyterNotebookPanel"), + setIframeRef: vi.fn(), + closeJupyterNotebookPanel: vi.fn(), + minimizeJupyterNotebookPanel: vi.fn(), }; mockNotebookMigrationService = { - getJupyterIframeURL: jasmine - .createSpy("getJupyterIframeURL") - .and.returnValue(Promise.resolve("http://localhost:8888")), + getJupyterIframeURL: vi.fn().mockResolvedValue("http://localhost:8888"), }; await TestBed.configureTestingModule({ @@ -63,27 +61,27 @@ describe("JupyterNotebookPanelComponent", () => { }); it("should create", () => { - spyOn(component, "checkIframeRef").and.stub(); + vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); expect(component).toBeTruthy(); }); it("should be hidden by default", () => { - spyOn(component, "checkIframeRef").and.stub(); - expect(component.isVisible).toBeFalse(); + vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); + expect(component.isVisible).toBe(false); }); it("should update visibility when service emits", async () => { - spyOn(component, "checkIframeRef").and.stub(); + vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); mockJupyterPanelService.jupyterNotebookPanelVisible$.next(true); await fixture.whenStable(); fixture.detectChanges(); - expect(component.isVisible).toBeTrue(); + expect(component.isVisible).toBe(true); }); it("should fetch and sanitize URL when panel becomes visible", async () => { - spyOn(component, "checkIframeRef").and.stub(); + vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); mockJupyterPanelService.jupyterNotebookPanelVisible$.next(true); await fixture.whenStable(); @@ -93,8 +91,7 @@ describe("JupyterNotebookPanelComponent", () => { expect(component.jupyterUrl.toString()).toContain("http://localhost:8888"); }); - it("should call setIframeRef when iframe exists and visible", done => { - spyOn(component, "checkIframeRef").and.stub(); + it("should call setIframeRef when iframe exists and visible", async () => { component.isVisible = true; const mockIframe = document.createElement("iframe"); @@ -102,14 +99,11 @@ describe("JupyterNotebookPanelComponent", () => { component.checkIframeRef(); - setTimeout(() => { - expect(mockJupyterPanelService.setIframeRef).toHaveBeenCalledWith(mockIframe); - done(); - }, 0); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(mockJupyterPanelService.setIframeRef).toHaveBeenCalledWith(mockIframe); }); - it("should NOT call setIframeRef if not visible", done => { - spyOn(component, "checkIframeRef").and.stub(); + it("should NOT call setIframeRef if not visible", async () => { component.isVisible = false; const mockIframe = document.createElement("iframe"); @@ -117,32 +111,30 @@ describe("JupyterNotebookPanelComponent", () => { component.checkIframeRef(); - setTimeout(() => { - expect(mockJupyterPanelService.setIframeRef).not.toHaveBeenCalled(); - done(); - }, 0); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(mockJupyterPanelService.setIframeRef).not.toHaveBeenCalled(); }); it("should close panel via service", () => { - spyOn(component, "checkIframeRef").and.stub(); + vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); component.closePanel(); expect(mockJupyterPanelService.closeJupyterNotebookPanel).toHaveBeenCalled(); }); it("should minimize panel and update visibility", () => { - spyOn(component, "checkIframeRef").and.stub(); + vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); component.isVisible = true; component.minimizePanel(); - expect(component.isVisible).toBeFalse(); + expect(component.isVisible).toBe(false); expect(mockJupyterPanelService.minimizeJupyterNotebookPanel).toHaveBeenCalled(); }); it("should clean up on destroy", () => { - spyOn(component, "checkIframeRef").and.stub(); - const nextSpy = spyOn(component["destroy$"], "next"); - const completeSpy = spyOn(component["destroy$"], "complete"); + vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); + const nextSpy = vi.spyOn(component["destroy$"] as any, "next"); + const completeSpy = vi.spyOn(component["destroy$"] as any, "complete"); component.ngOnDestroy(); From 29c7a680db3fd5f3489e2cd3caf1ac60f9d238ac Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Thu, 23 Jul 2026 15:18:51 -0700 Subject: [PATCH 03/21] import standalone jupyter notebook panel component in spec instead of declaring it --- .../jupyter-notebook-panel.component.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts index aa16feb8e20..003ac16cdf8 100644 --- a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts +++ b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts @@ -45,8 +45,7 @@ describe("JupyterNotebookPanelComponent", () => { }; await TestBed.configureTestingModule({ - declarations: [JupyterNotebookPanelComponent], - imports: [BrowserModule], + imports: [JupyterNotebookPanelComponent, BrowserModule], providers: [ { provide: JupyterPanelService, useValue: mockJupyterPanelService }, { provide: NotebookMigrationService, useValue: mockNotebookMigrationService }, From 3c6d726ec0895537ffde7e19e8785e70f1b10518 Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Mon, 27 Jul 2026 14:40:48 -0700 Subject: [PATCH 04/21] keep panel stream alive when iframe url fetch fails --- .../jupyter-notebook-panel.component.spec.ts | 31 +++++++++++++++++++ .../jupyter-notebook-panel.component.ts | 9 ++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts index 003ac16cdf8..6dc09d8e51a 100644 --- a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts +++ b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts @@ -90,6 +90,37 @@ describe("JupyterNotebookPanelComponent", () => { expect(component.jupyterUrl.toString()).toContain("http://localhost:8888"); }); + it("should not update jupyterUrl when the iframe URL fetch rejects", async () => { + vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + mockNotebookMigrationService.getJupyterIframeURL.mockRejectedValueOnce(new Error("network error")); + + mockJupyterPanelService.jupyterNotebookPanelVisible$.next(true); + + await fixture.whenStable(); + + expect(mockNotebookMigrationService.getJupyterIframeURL).toHaveBeenCalled(); + expect(component.jupyterUrl.toString()).toBe(""); + }); + + it("should keep handling visibility emissions after a failed fetch", async () => { + vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + mockNotebookMigrationService.getJupyterIframeURL + .mockRejectedValueOnce(new Error("network error")) + .mockResolvedValueOnce("http://localhost:9999"); + + mockJupyterPanelService.jupyterNotebookPanelVisible$.next(true); + await fixture.whenStable(); + + mockJupyterPanelService.jupyterNotebookPanelVisible$.next(true); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(mockNotebookMigrationService.getJupyterIframeURL).toHaveBeenCalledTimes(2); + expect(component.jupyterUrl.toString()).toContain("http://localhost:9999"); + }); + it("should call setIframeRef when iframe exists and visible", async () => { component.isVisible = true; diff --git a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.ts b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.ts index bd96dfbe10a..62bec7e9919 100644 --- a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.ts +++ b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.ts @@ -20,7 +20,7 @@ import { Component, ElementRef, OnDestroy, OnInit, ViewChild, AfterViewInit } from "@angular/core"; import { JupyterPanelService } from "../../service/jupyter-panel/jupyter-panel.service"; import { from, of, Subject } from "rxjs"; -import { switchMap, takeUntil } from "rxjs/operators"; +import { catchError, switchMap, takeUntil } from "rxjs/operators"; import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; import { NotebookMigrationService } from "../../service/notebook-migration/notebook-migration.service"; import { CommonModule } from "@angular/common"; @@ -58,7 +58,12 @@ export class JupyterNotebookPanelComponent implements OnInit, AfterViewInit, OnD return of(null); } - return from(this.notebookMigrationService.getJupyterIframeURL()); + return from(this.notebookMigrationService.getJupyterIframeURL()).pipe( + catchError(() => { + console.error("Failed to fetch Jupyter iframe URL."); + return of(null); + }) + ); }), takeUntil(this.destroy$) ) From c3a69fe5b461cce32a7787887bfb48e4ceb9bef6 Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Mon, 27 Jul 2026 14:56:11 -0700 Subject: [PATCH 05/21] stop logging jupyter iframe error when panel is hidden --- .../jupyter-notebook-panel.component.spec.ts | 31 +++++++++++++++++++ .../jupyter-notebook-panel.component.ts | 6 +++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts index 6dc09d8e51a..a47c3c396f6 100644 --- a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts +++ b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts @@ -59,6 +59,14 @@ describe("JupyterNotebookPanelComponent", () => { fixture.detectChanges(); }); + // Destroy the component so its subscriptions complete, and restore spies so a + // shared spy's call history (e.g. console.error) does not accumulate across + // tests. Mocks are not auto-reset by the Vitest config. + afterEach(() => { + fixture.destroy(); + vi.restoreAllMocks(); + }); + it("should create", () => { vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); expect(component).toBeTruthy(); @@ -145,6 +153,29 @@ describe("JupyterNotebookPanelComponent", () => { expect(mockJupyterPanelService.setIframeRef).not.toHaveBeenCalled(); }); + it("should not log an error when checkIframeRef runs while the panel is hidden", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + component.isVisible = false; + + component.checkIframeRef(); + + await new Promise(resolve => setTimeout(resolve, 0)); + expect(errorSpy).not.toHaveBeenCalled(); + expect(mockJupyterPanelService.setIframeRef).not.toHaveBeenCalled(); + }); + + it("should log an error when visible but the iframe ref is missing", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + component.isVisible = true; + component.iframeRef = undefined as any; + + component.checkIframeRef(); + + await new Promise(resolve => setTimeout(resolve, 0)); + expect(errorSpy).toHaveBeenCalledWith("Jupyter Iframe reference not found."); + expect(mockJupyterPanelService.setIframeRef).not.toHaveBeenCalled(); + }); + it("should close panel via service", () => { vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); component.closePanel(); diff --git a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.ts b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.ts index 62bec7e9919..cdf5244ca27 100644 --- a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.ts +++ b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.ts @@ -82,7 +82,11 @@ export class JupyterNotebookPanelComponent implements OnInit, AfterViewInit, OnD checkIframeRef(): void { setTimeout(() => { - if (this.isVisible && this.iframeRef?.nativeElement) { + if (!this.isVisible) { + // Panel hidden; no iframe to register. + return; + } + if (this.iframeRef?.nativeElement) { this.jupyterPanelService.setIframeRef(this.iframeRef.nativeElement); } else { console.error("Jupyter Iframe reference not found."); From bdd42ba54e1c209a076ddda6e3451a6631b88555 Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Mon, 27 Jul 2026 15:48:27 -0700 Subject: [PATCH 06/21] fix jupyter panel delete button hover selector --- .../jupyter-notebook-panel.component.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.scss b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.scss index 767ffa33e7d..75a17d6cc09 100644 --- a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.scss +++ b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.scss @@ -64,7 +64,7 @@ color: royalblue; } -.close-button:hover { +.delete-button:hover { color: royalblue; } From 42277e0313aac5e7a636175d0e31acba4e7f823f Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Mon, 27 Jul 2026 16:09:07 -0700 Subject: [PATCH 07/21] drop unnecessary browsermodule from jupyter panel spec --- .../jupyter-notebook-panel.component.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts index a47c3c396f6..d79597b1a0a 100644 --- a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts +++ b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts @@ -21,7 +21,6 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { JupyterNotebookPanelComponent } from "./jupyter-notebook-panel.component"; import { JupyterPanelService } from "../../service/jupyter-panel/jupyter-panel.service"; import { NotebookMigrationService } from "../../service/notebook-migration/notebook-migration.service"; -import { BrowserModule } from "@angular/platform-browser"; import { Subject } from "rxjs"; import { ElementRef } from "@angular/core"; @@ -45,7 +44,7 @@ describe("JupyterNotebookPanelComponent", () => { }; await TestBed.configureTestingModule({ - imports: [JupyterNotebookPanelComponent, BrowserModule], + imports: [JupyterNotebookPanelComponent], providers: [ { provide: JupyterPanelService, useValue: mockJupyterPanelService }, { provide: NotebookMigrationService, useValue: mockNotebookMigrationService }, From 05f99d33323b87eebc6ae2102353041b71cf8632 Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Mon, 27 Jul 2026 16:13:07 -0700 Subject: [PATCH 08/21] use fakeasync for jupyter checkiframeref timing tests --- .../jupyter-notebook-panel.component.spec.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts index d79597b1a0a..ff9baed019b 100644 --- a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts +++ b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts @@ -17,7 +17,7 @@ * under the License. */ -import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; import { JupyterNotebookPanelComponent } from "./jupyter-notebook-panel.component"; import { JupyterPanelService } from "../../service/jupyter-panel/jupyter-panel.service"; import { NotebookMigrationService } from "../../service/notebook-migration/notebook-migration.service"; @@ -128,52 +128,52 @@ describe("JupyterNotebookPanelComponent", () => { expect(component.jupyterUrl.toString()).toContain("http://localhost:9999"); }); - it("should call setIframeRef when iframe exists and visible", async () => { + it("should call setIframeRef when iframe exists and visible", fakeAsync(() => { component.isVisible = true; const mockIframe = document.createElement("iframe"); component.iframeRef = new ElementRef(mockIframe); component.checkIframeRef(); + tick(0); - await new Promise(resolve => setTimeout(resolve, 0)); expect(mockJupyterPanelService.setIframeRef).toHaveBeenCalledWith(mockIframe); - }); + })); - it("should NOT call setIframeRef if not visible", async () => { + it("should NOT call setIframeRef if not visible", fakeAsync(() => { component.isVisible = false; const mockIframe = document.createElement("iframe"); component.iframeRef = new ElementRef(mockIframe); component.checkIframeRef(); + tick(0); - await new Promise(resolve => setTimeout(resolve, 0)); expect(mockJupyterPanelService.setIframeRef).not.toHaveBeenCalled(); - }); + })); - it("should not log an error when checkIframeRef runs while the panel is hidden", async () => { + it("should not log an error when checkIframeRef runs while the panel is hidden", fakeAsync(() => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); component.isVisible = false; component.checkIframeRef(); + tick(0); - await new Promise(resolve => setTimeout(resolve, 0)); expect(errorSpy).not.toHaveBeenCalled(); expect(mockJupyterPanelService.setIframeRef).not.toHaveBeenCalled(); - }); + })); - it("should log an error when visible but the iframe ref is missing", async () => { + it("should log an error when visible but the iframe ref is missing", fakeAsync(() => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); component.isVisible = true; component.iframeRef = undefined as any; component.checkIframeRef(); + tick(0); - await new Promise(resolve => setTimeout(resolve, 0)); expect(errorSpy).toHaveBeenCalledWith("Jupyter Iframe reference not found."); expect(mockJupyterPanelService.setIframeRef).not.toHaveBeenCalled(); - }); + })); it("should close panel via service", () => { vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); From 6cb1ef43e30bd5fae8fe098b3d204ef1a79418e3 Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Mon, 27 Jul 2026 16:19:04 -0700 Subject: [PATCH 09/21] gate jupyter iframe render on available url --- .../jupyter-notebook-panel.component.html | 1 + .../jupyter-notebook-panel.component.spec.ts | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.html b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.html index 546f4e43571..36d43c23e53 100644 --- a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.html +++ b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.html @@ -53,6 +53,7 @@