This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-8102-317c822e6bb3464578cb7dfea3cce511888d42fe in repository https://gitbox.apache.org/repos/asf/texera.git
commit e944ce89c5902fc9671a1d8577eb110bbb23c337 Author: Meng Wang <[email protected]> AuthorDate: Sun Aug 30 03:36:08 2026 +0000 test(frontend): render the computing-unit rows and the metrics popover (#8102) ### What changes were proposed in this PR? `computing-unit-selection.component.html` never rendered its unit list or its metrics popover with data, so the per-unit row controls and every metric row sat unexecuted. This PR adds 17 cases that open both for real and drive them from the markup. No production code changes. - unit rows: selecting a row, the inline rename editor (focusout, Enter, Escape, and the click it swallows), and the pencil / plus / share / details / terminate icons — each asserting the row's own click handler did *not* also fire — plus the create-unit row - metrics popover: the CPU and RAM rows with their value, limit and percentage, and each optional row (GPU, JVM memory, shared memory) on **both** sides of its guard - the python-environment modal closing from its footer button and from `nzOnCancel` Template statement coverage goes from 71.35 % to 96.56 % (57 -> 7 uncovered lines), measured locally. The 7 that remain are inside the PVE modal body, outside this issue. A comment in the spec said the dropdown's overlay "does not attach under jsdom", and this turned out not to be the reason those lines were uncovered — the overlay does attach. The real obstacle is that ng-zorro gates it behind an `auditTime(150)` scheduled outside the Angular zone, which `fakeAsync`'s `tick()`/`flush()` cannot reach, `whenStable()` waits on forever (the component polls), and Vitest's fake timers cannot intercept because zone.js captured the native timer first. The helper therefore polls for the rendered rows rather than sleeping a fixed amount, so a slow runner costs extra iterations instead of a failure. The stale comment is replaced with this explanation. ### Any related issues, documentation, discussions? Closes #8099 ### How was this PR tested? `ng test --watch=false --include src/app/workspace/component/power-button/computing-unit-selection.component.spec.ts` -> 126 passed, run five times with identical results (five rather than three because of the polling helper). Five of the new assertions were verified to fail (non-zero exit) when deliberately broken. `prettier --check` and `eslint` are clean on the touched file. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 5) --- .../computing-unit-selection.component.spec.ts | 302 +++++++++++++++++++++ 1 file changed, 302 insertions(+) diff --git a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.spec.ts b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.spec.ts index 715c776827..a80d089af7 100644 --- a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.spec.ts +++ b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.spec.ts @@ -54,6 +54,8 @@ import { WorkflowMetadata } from "../../../dashboard/type/workflow-metadata.inte import { ExecutionState } from "../../types/execute-workflow.interface"; import { ComputingUnitActionsService } from "../../../common/service/computing-unit/computing-unit-actions/computing-unit-actions.service"; import { ComputingUnitMetadataComponent } from "../../../common/util/computing-unit.util"; +import { GuiConfigService } from "../../../common/service/gui-config.service"; +import { NzPopoverDirective } from "ng-zorro-antd/popover"; /** * Builds a fully-populated DashboardWorkflowComputingUnit for driving the @@ -1577,4 +1579,304 @@ describe("PowerButtonComponent", () => { } }); }); + + // ────────────────────────────────────────────────────────────────────────── + // Rows of the dropdown menu, driven through the markup ng-zorro stamps into + // the CDK overlay rather than by calling the handlers on the instance. + // ────────────────────────────────────────────────────────────────────────── + describe("dropdown menu rows (rendered)", () => { + /** + * ng-zorro gates the dropdown's overlay behind an `auditTime(150)` that it + * schedules outside the Angular zone, so neither `fakeAsync`'s `tick()` nor + * Vitest's fake timers reach it — zone.js captured the native timer before + * either could patch it. Poll for the rows instead of sleeping a fixed + * amount, so a slow runner costs extra iterations rather than a failure. + */ + async function openDropdown(): Promise<HTMLElement[]> { + fixture.debugElement.query(By.css(".computing-units-dropdown-button")).nativeElement.click(); + for (let i = 0; i < 80 && document.querySelectorAll(".computing-unit-option").length === 0; i++) { + await new Promise(resolve => setTimeout(resolve, 25)); + fixture.detectChanges(); + } + fixture.detectChanges(); + const rows = Array.from(document.querySelectorAll<HTMLElement>(".computing-unit-option")); + expect(rows.length).toBeGreaterThan(0); + return rows; + } + + function click(element: Element | null | undefined): void { + expect(element).toBeTruthy(); + element!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + fixture.detectChanges(); + } + + let selectSpy: ReturnType<typeof vi.spyOn>; + let sharingDefault: boolean; + + beforeEach(() => { + sharingDefault = TestBed.inject(GuiConfigService).env.sharingComputingUnitEnabled; + component.workflowId = 5; + component.allComputingUnits = [ + makeComputingUnit({ cuid: 1, name: "Alpha" }), + makeComputingUnit({ cuid: 2, name: "Beta" }), + ]; + selectSpy = vi + .spyOn(TestBed.inject(ComputingUnitStatusService), "selectComputingUnit") + .mockImplementation(() => {}); + fixture.detectChanges(); + }); + + afterEach(() => { + // The share test flips this flag. TestBed hands every test its own + // MockGuiConfigService, so it cannot leak today, but restore it anyway so the + // block stays correct if these tests ever share one config instance. + TestBed.inject(GuiConfigService).env.sharingComputingUnitEnabled = sharingDefault; + // Clear the overlay contents rather than the container itself, which CDK caches. + document.querySelectorAll(".cdk-overlay-container").forEach(el => (el.innerHTML = "")); + vi.restoreAllMocks(); + }); + + it("selects the unit whose row is clicked", async () => { + const rows = await openDropdown(); + + click(rows[1]); + + expect(component.selectedComputingUnit?.computingUnit.cuid).toBe(2); + expect(selectSpy).toHaveBeenCalledWith(5, 2); + }); + + it("commits a rename when the inline editor loses focus", async () => { + const renameSpy = vi + .spyOn(TestBed.inject(WorkflowComputingUnitManagingService), "renameComputingUnit") + .mockReturnValue(of({} as any)); + component.editingNameOfUnit = 1; + component.editingUnitName = "Alpha"; + const rows = await openDropdown(); + + const editor = rows[0].querySelector<HTMLInputElement>(".unit-name-edit-input"); + expect(editor?.value).toBe("Alpha"); + editor!.value = "Renamed"; + editor!.dispatchEvent(new FocusEvent("focusout", { bubbles: true })); + fixture.detectChanges(); + + expect(renameSpy).toHaveBeenCalledWith(1, "Renamed"); + }); + + it("commits a rename when Enter is pressed in the inline editor", async () => { + const renameSpy = vi + .spyOn(TestBed.inject(WorkflowComputingUnitManagingService), "renameComputingUnit") + .mockReturnValue(of({} as any)); + component.editingNameOfUnit = 1; + component.editingUnitName = "Alpha"; + const rows = await openDropdown(); + + const editor = rows[0].querySelector<HTMLInputElement>(".unit-name-edit-input"); + editor!.value = "Entered"; + editor!.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", bubbles: true })); + fixture.detectChanges(); + + expect(renameSpy).toHaveBeenCalledWith(1, "Entered"); + }); + + it("abandons the rename when Escape is pressed in the inline editor", async () => { + const renameSpy = vi.spyOn(TestBed.inject(WorkflowComputingUnitManagingService), "renameComputingUnit"); + component.editingNameOfUnit = 1; + component.editingUnitName = "Alpha"; + const rows = await openDropdown(); + + rows[0] + .querySelector<HTMLInputElement>(".unit-name-edit-input")! + .dispatchEvent(new KeyboardEvent("keyup", { key: "Escape", bubbles: true })); + fixture.detectChanges(); + + expect(component.editingNameOfUnit).toBeNull(); + expect(renameSpy).not.toHaveBeenCalled(); + }); + + it("swallows a click inside the inline editor instead of selecting the row", async () => { + component.editingNameOfUnit = 1; + component.editingUnitName = "Alpha"; + const rows = await openDropdown(); + + click(rows[0].querySelector(".unit-name-edit-input")); + + expect(selectSpy).not.toHaveBeenCalled(); + expect(component.editingNameOfUnit).toBe(1); + }); + it("opens the inline editor from the pencil without selecting the row", async () => { + const rows = await openDropdown(); + + click(rows[0].querySelector(".anticon-edit")); + + expect(component.editingNameOfUnit).toBe(1); + expect(component.editingUnitName).toBe("Alpha"); + expect(selectSpy).not.toHaveBeenCalled(); + }); + + it("opens the python-environment modal from the plus icon without selecting the row", async () => { + const rows = await openDropdown(); + + click(rows[1].querySelector(".anticon-plus")); + + expect(component.pveModalVisible).toBe(true); + expect(component.selectedComputingUnit?.computingUnit.cuid).toBe(2); + expect(selectSpy).not.toHaveBeenCalled(); + }); + + it("opens share access from the share icon without selecting the row", async () => { + TestBed.inject(GuiConfigService).env.sharingComputingUnitEnabled = true; + const shareSpy = vi + .spyOn(TestBed.inject(ComputingUnitActionsService), "openShareAccessModal") + .mockImplementation(() => {}); + fixture.detectChanges(); + const rows = await openDropdown(); + + click(rows[0].querySelector(".anticon-share-alt")); + + expect(shareSpy).toHaveBeenCalledWith(1, true); + expect(selectSpy).not.toHaveBeenCalled(); + }); + + it("opens the metadata modal from the eye icon without selecting the row", async () => { + const createSpy = vi.spyOn(TestBed.inject(NzModalService), "create").mockReturnValue({} as any); + const rows = await openDropdown(); + + click(rows[1].querySelector(".anticon-eye")); + + expect(createSpy).toHaveBeenCalledTimes(1); + expect(createSpy.mock.calls[0][0].nzData).toBe(component.allComputingUnits[1]); + expect(selectSpy).not.toHaveBeenCalled(); + }); + + it("terminates from the delete icon without selecting the row", async () => { + const terminateSpy = vi + .spyOn(TestBed.inject(ComputingUnitActionsService), "confirmAndTerminate") + .mockImplementation(() => {}); + const rows = await openDropdown(); + + click(rows[0].querySelector(".anticon-delete")); + + expect(terminateSpy).toHaveBeenCalledWith(1, component.allComputingUnits[0]); + expect(selectSpy).not.toHaveBeenCalled(); + }); + + it("opens the create-unit modal from the row below the divider", async () => { + await openDropdown(); + + click(document.querySelector(".create-computing-unit")); + + expect(component.addComputeUnitModalVisible).toBe(true); + }); + }); + + // ────────────────────────────────────────────────────────────────────────── + // The metrics popover's own template, and the PVE modal's close controls. + // ────────────────────────────────────────────────────────────────────────── + describe("metrics popover (rendered)", () => { + /** A running kubernetes unit whose resource limits the popover reads. */ + function unitWithResources(resource: Partial<Record<string, string>>): DashboardWorkflowComputingUnit { + const unit = makeComputingUnit({ cuid: 7, name: "Metrics" }); + unit.computingUnit.resource = { ...unit.computingUnit.resource, ...(resource as any) }; + return unit; + } + + /** Opens the metrics popover and returns the metric names it rendered, in order. */ + async function openMetrics(): Promise<{ names: string[]; overlay: HTMLElement }> { + const container = fixture.debugElement.query(By.css("#metrics-container-id")); + expect(container).toBeTruthy(); + container.injector.get(NzPopoverDirective).show(); + fixture.detectChanges(); + // the tooltip base positions its overlay in a microtask + await Promise.resolve(); + fixture.detectChanges(); + const overlay = document.querySelector<HTMLElement>(".cdk-overlay-container")!; + const names = Array.from(overlay.querySelectorAll(".resource-metrics .general-metric .metric-name")).map( + el => el.textContent?.trim() ?? "" + ); + return { names, overlay }; + } + + afterEach(() => { + document.querySelectorAll(".cdk-overlay-container").forEach(el => (el.innerHTML = "")); + vi.restoreAllMocks(); + }); + + it("renders the CPU and RAM rows with their value, limit and percentage", async () => { + component.selectedComputingUnit = unitWithResources({ cpuLimit: "2", memoryLimit: "4Gi" }); + fixture.detectChanges(); + + const { names, overlay } = await openMetrics(); + + expect(names).toContain("CPU"); + expect(names).toContain("RAM"); + const cpu = overlay.querySelector(".cpu-metric .metric-value")!; + expect(cpu.querySelector(".metric-unit")?.textContent).toContain("2"); + expect(cpu.querySelector(".metric-percentage")?.textContent).toContain("%"); + const memory = overlay.querySelector(".memory-metric .metric-value")!; + expect(memory.querySelector(".metric-unit")?.textContent).toContain("4"); + expect(memory.querySelector(".metric-percentage")?.textContent).toContain("%"); + }); + + it("adds the GPU, JVM and shared-memory rows when the unit declares those limits", async () => { + component.gpuOptions = ["0", "1"]; + component.selectedComputingUnit = unitWithResources({ + gpuLimit: "2", + jvmMemorySize: "1Gi", + shmSize: "64Mi", + }); + fixture.detectChanges(); + + const { names, overlay } = await openMetrics(); + + expect(names).toEqual(["CPU", "RAM", "GPU", "JVM Memory Size", "Shared Memory Size"]); + expect(overlay.textContent).toContain("2 GPU(s)"); + }); + + it("drops the GPU row when the deployment offers no GPU at all", async () => { + component.gpuOptions = ["0"]; + component.selectedComputingUnit = unitWithResources({ gpuLimit: "2" }); + fixture.detectChanges(); + + expect((await openMetrics()).names).not.toContain("GPU"); + }); + + it("drops each optional row whose limit reads as zero or NaN", async () => { + component.gpuOptions = ["0", "1"]; + component.selectedComputingUnit = unitWithResources({ + gpuLimit: "0", + jvmMemorySize: "NaN", + shmSize: "0", + }); + fixture.detectChanges(); + + expect((await openMetrics()).names).toEqual(["CPU", "RAM"]); + }); + + it("has no popover content to show while no unit is selected", () => { + component.selectedComputingUnit = null; + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css("#metrics-container-id"))).toBeNull(); + }); + + it("closes the python-environment modal from its footer button and its cancel", () => { + component.pveModalVisible = true; + fixture.detectChanges(); + + document.querySelector<HTMLButtonElement>(".footer-all button")!.click(); + fixture.detectChanges(); + + expect(component.pveModalVisible).toBe(false); + + component.pveModalVisible = true; + fixture.detectChanges(); + // the create-unit child renders an nz-modal too, so pick this one by its title + const pveModal = fixture.debugElement + .queryAll(By.css("nz-modal")) + .find(modal => modal.componentInstance?.nzTitle === "Python Environments"); + pveModal!.triggerEventHandler("nzOnCancel", undefined); + + expect(component.pveModalVisible).toBe(false); + }); + }); });
