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-7966-996fe6ea16b7a409cb5adfbbb206801fe28dd9e2 in repository https://gitbox.apache.org/repos/asf/texera.git
commit bf61a206e5bdbaf9f68b7f2041814e4a617df5c3 Author: Meng Wang <[email protected]> AuthorDate: Tue Aug 25 22:25:23 2026 +0000 test(frontend): render the execution-history table instead of calling its handlers (#7966) ### What changes were proposed in this PR? `workflow-execution-history.component.spec.ts` exercised the component's methods directly, so the template's own event bindings were never the code under test. This PR adds 15 cases that drive them through the rendered markup instead — query with `By.css`, fire with `triggerEventHandler`, assert on the resulting state. No production code changes. - the search input's `keyup.enter` and `ngModelChange` - the instructions popover's search-criteria help (opened through `NzPopoverDirective`, read back out of the cdk overlay) - the card's group bookmark and group-delete confirmation - the paginator's `nzPageIndexChange` and `nzPageSizeChange` - the header checkbox (check and uncheck all) and both sort arrows, ascending and descending - each row's checkbox, bookmark star, rename pencil (committed by both focusout and Enter), runtime-statistics button and delete confirmation `NoopAnimationsModule` joins the shared TestBed imports so the popover's overlay can open. Template statement coverage of `workflow-execution-history.component.html` goes from 69.66 % to 100 % (26 -> 0 uncovered lines), measured locally. Three of the reported gaps (the `keyup.enter`, `nzPageIndexChange` and `focusout` bindings) turned out to be source-map phantoms: their uncovered statements sit at column ranges past the end of those lines, and the real code behind them is the binding on the following line. Firing those adjacent bindings closes them. The determinism rules from #6542 are kept: Plotly is not mocked, the fixture stays attached to `document.body`, no `DatePipe` output or layout geometry is asserted, and the popover closes again at the end of its test. ### Any related issues, documentation, discussions? Closes #7960 ### How was this PR tested? `ng test --watch=false --include src/app/dashboard/component/user/user-workflow/ngbd-modal-workflow-executions/workflow-execution-history.component.spec.ts` -> 61 passed; run three times with identical results. 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) --- .../workflow-execution-history.component.spec.ts | 237 ++++++++++++++++++++- 1 file changed, 236 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/dashboard/component/user/user-workflow/ngbd-modal-workflow-executions/workflow-execution-history.component.spec.ts b/frontend/src/app/dashboard/component/user/user-workflow/ngbd-modal-workflow-executions/workflow-execution-history.component.spec.ts index 2cdfc23f70..da1328b206 100644 --- a/frontend/src/app/dashboard/component/user/user-workflow/ngbd-modal-workflow-executions/workflow-execution-history.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/user-workflow/ngbd-modal-workflow-executions/workflow-execution-history.component.spec.ts @@ -36,6 +36,10 @@ import { StubUserService } from "../../../../../common/service/user/stub-user.se import { OperatorMetadataService } from "../../../../../workspace/service/operator-metadata/operator-metadata.service"; import { StubOperatorMetadataService } from "../../../../../workspace/service/operator-metadata/stub-operator-metadata.service"; import { commonTestProviders } from "../../../../../common/testing/test-utils"; +import { DebugElement } from "@angular/core"; +import { By } from "@angular/platform-browser"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { NzPopoverDirective } from "ng-zorro-antd/popover"; function makeEntry(overrides: Partial<WorkflowExecutionsEntry> = {}): WorkflowExecutionsEntry { return { @@ -144,7 +148,7 @@ describe("WorkflowExecutionHistoryComponent", () => { notificationService = { error: vi.fn() }; await TestBed.configureTestingModule({ - imports: [WorkflowExecutionHistoryComponent, HttpClientTestingModule, NzModalModule], + imports: [WorkflowExecutionHistoryComponent, HttpClientTestingModule, NzModalModule, NoopAnimationsModule], providers: [ { provide: WorkflowExecutionsService, useValue: executionsService }, { provide: NotificationService, useValue: notificationService }, @@ -729,4 +733,235 @@ describe("WorkflowExecutionHistoryComponent", () => { expect(fixture.nativeElement.querySelectorAll(".ant-card-actions i")).toHaveLength(2); }); }); + + // ────────────────────────────────────────────────────────────────────────── + // Interactions driven through the rendered markup rather than the instance, + // so the template's own event bindings are the code under test. + // ────────────────────────────────────────────────────────────────────────── + describe("template-driven interactions", () => { + function headerCells(): DebugElement[] { + return fixture.debugElement.queryAll(By.css("thead th")); + } + + function bodyRows(): DebugElement[] { + return fixture.debugElement.queryAll(By.css("tbody tr")); + } + + /** The card's two group actions render in [nzActions] order: bookmark, then delete. */ + function groupActions(): { bookmark: DebugElement; delete: DebugElement } { + const icons = fixture.debugElement.queryAll(By.css(".ant-card-actions i")); + return { bookmark: icons[0], delete: icons[1] }; + } + + function sortButtonFor(column: string): DebugElement { + const header = headerCells().find(th => (th.nativeElement as HTMLElement).textContent?.includes(column)); + return header!.query(By.css("button")); + } + + /** Row buttons in template order: rename (.rename-icon), runtime statistics, delete. */ + function rowButtons(row: DebugElement): { statistics: DebugElement; delete: DebugElement } { + const buttons = row.queryAll(By.css("button:not(.rename-icon)")); + return { statistics: buttons[0], delete: buttons[1] }; + } + + it("searches from the rendered input when Enter is pressed in it", async () => { + await setup(); + const searchSpy = vi.spyOn(component.fuse, "search").mockReturnValue(fuseResults(entries[0])); + component.executionSearchValue = "status:running"; + fixture.detectChanges(); + + fixture.debugElement.query(By.css("input[nz-input]")).triggerEventHandler("keyup.enter", {}); + + expect(searchSpy).toHaveBeenCalledWith({ $and: [{ $path: ["status"], $val: "1" }] }); + expect(component.workflowExecutionsDisplayedList).toEqual([entries[0]]); + }); + + it("feeds the autocomplete as the rendered input changes", async () => { + await setup(); + + fixture.debugElement.query(By.css("input[nz-input]")).triggerEventHandler("ngModelChange", "status:run"); + + expect(component.filteredExecutionInfo).toEqual(["status:running"]); + }); + + it("renders the search-criteria help once the instructions popover is opened", async () => { + await setup(); + const popover = fixture.debugElement.query(By.directive(NzPopoverDirective)).injector.get(NzPopoverDirective); + + popover.show(); + fixture.detectChanges(); + // the tooltip base positions its overlay in a microtask, so flush that before reading it + await Promise.resolve(); + fixture.detectChanges(); + + // the popover content lives in the cdk overlay, outside the fixture's own element + const help = document.querySelector(".cdk-overlay-container")?.textContent ?? ""; + expect(help).toContain("We support the following search criteria"); + expect(help).toContain("executionName"); + expect(help).toContain("user:John"); + expect(help).toContain("status:initializing/running/paused/completed/failed/killed"); + expect(help).toContain("using double quotes to enclose the name is"); + expect(help).toContain('Example: "Untitled Execution" user:John'); + + popover.hide(); + fixture.detectChanges(); + }); + + it("bookmarks the whole selection from the card's group action", async () => { + await setup(); + component.onItemChecked(entries[0], true); + component.onItemChecked(entries[2], true); + fixture.detectChanges(); + + groupActions().bookmark.triggerEventHandler("click", new MouseEvent("click")); + + expect(executionsService.groupSetIsBookmarked).toHaveBeenCalledWith(1, [1, 3], false); + expect(entries[0].bookmarked).toBe(true); + expect(entries[2].bookmarked).toBe(true); + }); + + it("deletes the whole selection when the group popconfirm is confirmed", async () => { + await setup(); + component.onItemChecked(entries[0], true); + fixture.detectChanges(); + + groupActions().delete.triggerEventHandler("nzOnConfirm", undefined); + + expect(executionsService.groupDeleteWorkflowExecutions).toHaveBeenCalledWith(1, [1]); + expect(component.allExecutionEntries.map(e => e.eId)).toEqual([2, 3]); + expect(component.setOfEid.size).toBe(0); + }); + + it("re-slices the table when the paginator reports a new page index", async () => { + const many = Array.from({ length: 15 }, (_, i) => makeEntry({ eId: i + 1, name: `run ${i + 1}` })); + await setup({ entries: many }); + + const table = fixture.debugElement.query(By.css("nz-table")); + table.triggerEventHandler("nzPageIndexChange", 2); + + expect(component.currentPageIndex).toBe(2); + expect(component.workflowExecutionsDisplayedList!.map(e => e.eId)).toEqual([11, 12, 13, 14, 15]); + + table.triggerEventHandler("nzPageSizeChange", 5); + + expect(component.pageSize).toBe(5); + expect(component.workflowExecutionsDisplayedList!.map(e => e.eId)).toEqual([6, 7, 8, 9, 10]); + }); + + it("selects and clears every row from the header checkbox", async () => { + await setup(); + headerCells()[0].triggerEventHandler("nzCheckedChange", true); + + expect(component.setOfEid).toEqual(new Set([1, 2, 3])); + expect(component.checked).toBe(true); + + headerCells()[0].triggerEventHandler("nzCheckedChange", false); + + expect(component.setOfEid.size).toBe(0); + expect(component.checked).toBe(false); + }); + + it("sorts descending from a header whose arrow points down", async () => { + await setup(); + // showORhide[2] is false for "Name (ID)", so its header renders the descending button. + sortButtonFor("Name (ID)").triggerEventHandler("click", new MouseEvent("click")); + + expect(component.workflowExecutionsDisplayedList!.map(e => e.name)).toEqual([ + "Untitled Execution", + "twitter analysis", + "reddit crawl", + ]); + expect(component.showORhide[2]).toBe(true); + }); + + it("sorts ascending from a header whose arrow points up", async () => { + // startingTime deliberately runs against eId order, so the assertion below + // fails if the click leaves the rows where they were. + await setup({ + entries: [ + makeEntry({ eId: 1, startingTime: 3000 }), + makeEntry({ eId: 2, startingTime: 1000 }), + makeEntry({ eId: 3, startingTime: 2000 }), + ], + }); + expect(component.workflowExecutionsDisplayedList!.map(e => e.eId)).toEqual([1, 2, 3]); + + // showORhide[4] starts true, so "Execution Start Time" renders the ascending button. + sortButtonFor("Execution Start Time").triggerEventHandler("click", new MouseEvent("click")); + + expect(component.workflowExecutionsDisplayedList!.map(e => e.eId)).toEqual([2, 3, 1]); + expect(component.showORhide[4]).toBe(false); + }); + + it("adds and removes one row from the selection through its own checkbox", async () => { + await setup(); + bodyRows()[1].query(By.css("td")).triggerEventHandler("nzCheckedChange", true); + + expect(component.setOfEid).toEqual(new Set([2])); + expect(component.checked).toBe(false); + + bodyRows()[1].query(By.css("td")).triggerEventHandler("nzCheckedChange", false); + + expect(component.setOfEid.size).toBe(0); + }); + + it("toggles one row's bookmark from its star icon", async () => { + await setup(); + bodyRows()[0].query(By.css("i.bookmark-icon")).triggerEventHandler("click", new MouseEvent("click")); + + expect(executionsService.groupSetIsBookmarked).toHaveBeenCalledWith(1, [1], false); + expect(component.workflowExecutionsDisplayedList![0].bookmarked).toBe(true); + }); + + it("opens the rename editor from the pencil and persists the new name on focusout", async () => { + await setup(); + bodyRows()[0].query(By.css("button.rename-icon")).triggerEventHandler("click", new MouseEvent("click")); + fixture.detectChanges(); + + expect(component.workflowExecutionsIsEditingName).toEqual([0]); + const editor = bodyRows()[0].query(By.css("input[placeholder='twitter analysis']")); + expect(editor).toBeTruthy(); + + (editor.nativeElement as HTMLInputElement).value = "renamed run"; + editor.triggerEventHandler("focusout", {}); + + expect(executionsService.updateWorkflowExecutionsName).toHaveBeenCalledWith(1, 1, "renamed run"); + expect(component.workflowExecutionsDisplayedList![0].name).toBe("renamed run"); + expect(component.workflowExecutionsIsEditingName).toEqual([]); + }); + + it("persists the new name when Enter is pressed in the rename editor", async () => { + await setup(); + bodyRows()[0].query(By.css("button.rename-icon")).triggerEventHandler("click", new MouseEvent("click")); + fixture.detectChanges(); + + const editor = bodyRows()[0].query(By.css("input[placeholder='twitter analysis']")); + (editor.nativeElement as HTMLInputElement).value = "enter rename"; + editor.triggerEventHandler("keyup.enter", {}); + + expect(executionsService.updateWorkflowExecutionsName).toHaveBeenCalledWith(1, 1, "enter rename"); + expect(component.workflowExecutionsIsEditingName).toEqual([]); + }); + + it("opens the runtime-statistics modal from a row's chart button", async () => { + await setup(); + const stats = [{ operatorId: "op-1" }] as unknown as WorkflowRuntimeStatistics[]; + executionsService.retrieveWorkflowRuntimeStatistics.mockReturnValue(of(stats)); + const createSpy = vi.spyOn(TestBed.inject(NzModalService), "create").mockReturnValue({} as NzModalRef); + + rowButtons(bodyRows()[0]).statistics.triggerEventHandler("click", new MouseEvent("click")); + + // row 0 is eId 1 on computing unit 3 + expect(executionsService.retrieveWorkflowRuntimeStatistics).toHaveBeenCalledWith(1, 1, 3); + expect((createSpy.mock.calls[0][0] as ModalOptions).nzData).toEqual({ workflowRuntimeStatistics: stats }); + }); + + it("deletes one row when its popconfirm is confirmed", async () => { + await setup(); + rowButtons(bodyRows()[0]).delete.triggerEventHandler("nzOnConfirm", undefined); + + expect(executionsService.groupDeleteWorkflowExecutions).toHaveBeenCalledWith(1, [1]); + expect(component.allExecutionEntries.map(e => e.eId)).toEqual([2, 3]); + }); + }); });
