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-7629-09fa90337a8fb7e109adebd24ccb0d4b81f46ad9 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 379208ecffea6a9aeddcfe940ea214260a3cf7a7 Author: Xinyuan Lin <[email protected]> AuthorDate: Fri Aug 14 06:03:53 2026 +0000 test(frontend): render the agent panel with its real children (#7629) ### What changes were proposed in this PR? `agent-panel.component.html` reported **0 of 49 lines** while its own `.ts` sat at **133/133 with nothing missed**. That pairing can only be attribution loss — this template is one of the better-tested in the frontend. The cause is #7458: the spec stubs its children out with `TestBed.overrideComponent`, and any override re-JITs the component from its decorator metadata, leaving the re-compiled template with no source map back to the `.html`. Adds a `describe` block that renders the component with its **real** children: | | Before | After | |---|---|---| | `agent-panel.component.html` | 0/49 | **49/49** | | `agent-panel.component.ts` | 133/133 | 133/133 | The block keeps its own `TestBed`, so the 42 existing tests keep their stubs and assertions untouched. Same remedy as merged PR #7535. Rendering the real children also incidentally lifts the child templates' own coverage. ### Verification 18 mutations applied and reverted, production diff empty each time. **Two of my new tests then turned out to claim more than they could observe, and both are fixed:** | Test | Why it could not fail | Fix | |---|---|---| | "force-renders every tab body: the registration form and one chat per agent" | the registration tab **is** the selected tab, so its body renders whether or not `[nzForceRender]` is set — flipping it to `false` left the suite green | select an agent tab first, so the registration body is present only if force-rendered; the tautological `selectedTabIndex` assertion was dropped | | "the close button ... deletes that agent without selecting its tab" | `selectedTabIndex` was already 0 and `activateAgent` already un-called, so both assertions held with `event.stopPropagation()` deleted | start on the first agent's tab and assert the click does not activate the neighbour | Both exposing mutations — the registration tab losing `nzForceRender`, and the close handler losing `stopPropagation` — are now red. That second one is worth spelling out: the mutation *did* turn the suite red before the fix, but the failure came from a **pre-existing** test elsewhere in the file, while the new test stayed green. A red suite is not evidence that the test under discussion pins anything. ### Deliberately not included Nothing in this PR pins the child components' internals; the assertions stay on this template's own structure and branches. No production file is touched. ### Any related issues, documentation, discussions? Closes #7628 ### How was this PR tested? ``` npx ng test --watch=false --include="**/agent-panel.component.spec.ts" ``` ``` Test Files 1 passed (1) Tests 51 passed (51) ``` 9 new on top of the existing 42. Coverage re-measured by reverting the spec, running with `--coverage`, and restoring. `yarn format:ci` passes. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 5) Co-authored-by: Meng Wang <[email protected]> --- .../agent-panel/agent-panel.component.spec.ts | 233 ++++++++++++++++++++- 1 file changed, 232 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/workspace/component/agent/agent-panel/agent-panel.component.spec.ts b/frontend/src/app/workspace/component/agent/agent-panel/agent-panel.component.spec.ts index 0ce5e44578..5e92ffe58a 100644 --- a/frontend/src/app/workspace/component/agent/agent-panel/agent-panel.component.spec.ts +++ b/frontend/src/app/workspace/component/agent/agent-panel/agent-panel.component.spec.ts @@ -22,13 +22,22 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { HttpClientTestingModule } from "@angular/common/http/testing"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { By } from "@angular/platform-browser"; +import { CdkDrag, CdkDragHandle, CdkDragStart } from "@angular/cdk/drag-drop"; +import { NzResizableDirective } from "ng-zorro-antd/resizable"; +import { NzModalService } from "ng-zorro-antd/modal"; +import { MarkdownModule } from "ngx-markdown"; import { Observable, Subject, of, throwError } from "rxjs"; import { AgentPanelComponent } from "./agent-panel.component"; import { AgentRegistrationComponent } from "./agent-registration/agent-registration.component"; import { AgentChatComponent } from "./agent-chat/agent-chat.component"; -import { AgentInfo, AgentService } from "../../../service/agent/agent.service"; +import { AgentInfo, AgentService, ModelType } from "../../../service/agent/agent.service"; +import { AgentState, ReActStep } from "../../../service/agent/agent-types"; import { WorkflowActionService } from "../../../service/workflow-graph/model/workflow-action.service"; import { NotificationService } from "../../../../common/service/notification/notification.service"; +import { WorkflowPersistService } from "../../../../common/service/workflow-persist/workflow-persist.service"; +import { ComputingUnitStatusService } from "../../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service"; +import { ComputingUnitState } from "../../../../common/type/computing-unit-connection.interface"; +import { Workflow } from "../../../../common/type/workflow"; import { commonTestProviders } from "../../../../common/testing/test-utils"; const CURRENT_WID = 42; @@ -674,4 +683,226 @@ describe("AgentPanelComponent", () => { expect(localStorage.getItem("agent-panel-style")).toBeNull(); }); }); + + /** + * The blocks above stub the children with TestBed.overrideComponent. Any override + * makes Angular re-JIT the panel from its decorator metadata, and the recompiled + * template loses the source mapping back to agent-panel.component.html — the + * bindings still run but none of them are attributed (issue #7458). This block + * configures its own TestBed with no override, renders the real children, and + * asserts on the DOM the panel template produces, so the stubbed tests above and + * their assertions stay as they are. + */ + describe("rendered template", () => { + const MODEL: ModelType = { id: "gpt", name: "GPT", description: "desc", icon: "robot" }; + + /** + * Superset of MockAgentService covering the calls the real agent-chat and + * agent-registration children make while initializing; the panel and both + * children resolve the same AgentService token. + */ + class FullMockAgentService extends MockAgentService { + public scrollToStepSubject = new Subject<{ agentId: string; messageId: string; stepId: number }>(); + public scrollToStep$ = this.scrollToStepSubject.asObservable(); + + // agent-chat + public ensureWorkflowPolling = vi.fn(); + public getAgentState = vi.fn((): Observable<AgentState> => of(AgentState.AVAILABLE)); + public getAgentStateObservable = vi.fn((): Observable<AgentState> => of(AgentState.AVAILABLE)); + public getReActStepsObservable = vi.fn((): Observable<ReActStep[]> => of([])); + public getHeadIdObservable = vi.fn((): Observable<string | null> => of(null)); + public getWorkflowObservable = vi.fn((): Observable<Workflow | null> => of(null)); + public setHoveredMessage = vi.fn(); + + // agent-registration + public fetchModelTypes = vi.fn((): Observable<ModelType[]> => of([MODEL])); + public createAgent = vi.fn(); + } + + let service: FullMockAgentService; + + beforeEach(async () => { + TestBed.resetTestingModule(); + service = new FullMockAgentService(); + + await TestBed.configureTestingModule({ + // MarkdownModule.forRoot() backs the <markdown> elements inside agent-chat and + // NzModalService backs its declarative <nz-modal> (same wiring as its own spec). + imports: [AgentPanelComponent, HttpClientTestingModule, NoopAnimationsModule, MarkdownModule.forRoot()], + providers: [ + NzModalService, + { provide: AgentService, useValue: service }, + { provide: WorkflowActionService, useValue: { ...workflowAction, reloadWorkflow: vi.fn() } }, + { provide: NotificationService, useValue: notification }, + { provide: WorkflowPersistService, useValue: { setWorkflowPersistFlag: vi.fn() } }, + { provide: ComputingUnitStatusService, useValue: { getStatus: () => of(ComputingUnitState.Running) } }, + ...commonTestProviders, + ], + }).compileComponents(); + }); + + function container(): HTMLElement { + return fixture.nativeElement.querySelector("#agent-container") as HTMLElement; + } + + function tabHeaders(): HTMLElement[] { + return Array.from(fixture.nativeElement.querySelectorAll(".ant-tabs-tab")); + } + + function chats(): AgentChatComponent[] { + return fixture.debugElement.queryAll(By.directive(AgentChatComponent)).map(d => d.componentInstance); + } + + it("reveals the body and sizes it from the bound width and height when opened", () => { + createComponent(); + const content = fixture.nativeElement.querySelector("#content") as HTMLElement; + expect(content.hidden).toBe(true); + expect(container().style.width).toBe("0px"); + + (fixture.nativeElement.querySelector("#agent-docked-button") as HTMLButtonElement).click(); + fixture.detectChanges(); + + expect(content.hidden).toBe(false); + expect(container().style.width).toBe("400px"); + expect(container().style.height).toBe(`${component.height}px`); + expect((fixture.nativeElement.querySelector("#title") as HTMLElement).textContent?.trim()).toBe("AI Agents"); + + (fixture.nativeElement.querySelector("#return-button li") as HTMLElement).click(); + fixture.detectChanges(); + + expect(content.hidden).toBe(true); + expect(container().style.width).toBe("0px"); + }); + + it("bounds the resizable box by the configured minimum and a fraction of the window", () => { + createComponent(); + const resizable = fixture.debugElement + .query(By.directive(NzResizableDirective)) + .injector.get(NzResizableDirective); + + expect(resizable.nzMinWidth).toBe(400); + expect(resizable.nzMinHeight).toBe(450); + expect(resizable.nzMaxWidth).toBe(window.innerWidth * 0.9); + expect(resizable.nzMaxHeight).toBe(window.innerHeight * 0.85); + }); + + it("applies a resize reported by the resizable directive to the rendered box", () => { + createComponent(); + vi.spyOn(window, "requestAnimationFrame").mockImplementation(cb => { + cb(0); + return 1; + }); + const resizable = fixture.debugElement + .query(By.directive(NzResizableDirective)) + .injector.get(NzResizableDirective); + + resizable.nzResize.emit({ width: 640, height: 520 }); + fixture.detectChanges(); + + expect(container().style.width).toBe("640px"); + expect(container().style.height).toBe("520px"); + }); + + it("drags from the title bar within the workspace and undocks on drag start", () => { + createComponent(); + component.dragPosition = { x: 11, y: 22 }; + fixture.detectChanges(); + const dragDebug = fixture.debugElement.query(By.directive(CdkDrag)); + const drag = dragDebug.injector.get(CdkDrag); + + expect(dragDebug.nativeElement).toBe(container()); + expect(drag.boundaryElement).toBe("texera-workspace"); + expect(drag.freeDragPosition).toEqual({ x: 11, y: 22 }); + // The whole box is draggable but only the <h4> title is a handle. + expect(fixture.debugElement.query(By.directive(CdkDragHandle)).nativeElement.id).toBe("title"); + + expect(component.isDocked).toBe(true); + drag.started.emit({ source: drag } as CdkDragStart); + expect(component.isDocked).toBe(false); + }); + + it("force-renders every tab body: the registration form and one chat per agent", () => { + service.agentList = [makeAgent("a"), makeAgent("b")]; + createComponent(); + + // The agent tabs are unselected here, so their bodies exist only because they are + // force-rendered. + const rendered = fixture.nativeElement.querySelectorAll("texera-agent-chat .agent-chat-container"); + expect(rendered.length).toBe(2); + expect(chats().map(c => c.agentInfo?.id)).toEqual(["a", "b"]); + + // The registration form needs the mirror case: while tab 0 is selected it renders whether or + // not it is force-rendered, so asserting it there pins nothing. Move off it first. + component.selectedTabIndex = 1; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelectorAll("texera-agent-registration .model-card").length).toBe(1); + }); + + it("clicking an agent's tab header activates that agent and marks only its chat active", () => { + service.agentList = [makeAgent("a"), makeAgent("b")]; + createComponent(); + expect(chats().map(c => c.isActive)).toEqual([false, false]); + + // Header 0 is the registration tab, so agent "b" sits behind header 2. + (tabHeaders()[2].querySelector(".ant-tabs-tab-btn") as HTMLElement).click(); + fixture.detectChanges(); + + expect(service.activateAgent).toHaveBeenCalledWith("b"); + expect(chats().map(c => c.isActive)).toEqual([false, true]); + }); + + it("disables the tab of an agent bound to another workflow so a click cannot select it", () => { + service.agentList = [makeAgent("local"), makeDelegateAgent("foreign", 99)]; + createComponent(); + + const headers = tabHeaders(); + expect(headers[1].classList.contains("ant-tabs-tab-disabled")).toBe(false); + expect(headers[2].classList.contains("ant-tabs-tab-disabled")).toBe(true); + + (headers[2].querySelector(".ant-tabs-tab-btn") as HTMLElement).click(); + fixture.detectChanges(); + + // Disabled by the template, so onTabSelectChange never runs and never warns. + expect(notification.warning).not.toHaveBeenCalled(); + expect(service.activateAgent).not.toHaveBeenCalled(); + expect(chats().map(c => c.isActive)).toEqual([false, false]); + }); + + it("the close button on a tab header deletes that agent without selecting its tab", () => { + vi.spyOn(window, "confirm").mockReturnValue(true); + service.agentList = [makeAgent("a"), makeAgent("b")]; + createComponent(); + + const closeButtons = fixture.nativeElement.querySelectorAll(".agent-tab-close"); + expect(closeButtons.length).toBe(2); + // Start on the FIRST agent's tab. Asserting from tab 0 with activateAgent never called + // proves nothing: both hold with the stopPropagation deleted, because neither value moves. + // From here, a click that propagated would activate agent "b". + component.selectedTabIndex = 1; + fixture.detectChanges(); + (service.activateAgent as unknown as { mock: { calls: unknown[] } }).mock.calls.length = 0; + + (closeButtons[1] as HTMLButtonElement).click(); + fixture.detectChanges(); + + expect(service.deleteAgent).toHaveBeenCalledWith("b"); + // The click is stopped, so the tab underneath it never activates its agent. + expect(service.activateAgent).not.toHaveBeenCalledWith("b"); + }); + + it("offers resize handles on the left and bottom edges only", () => { + createComponent(); + const handles = Array.from( + fixture.nativeElement.querySelectorAll("nz-resize-handles .nz-resizable-handle") as NodeListOf<HTMLElement> + ).map(el => + // Each handle also carries a cursor-type class; keep only the direction one. + Array.from(el.classList) + .filter(name => name.startsWith("nz-resizable-handle-") && !name.includes("cursor-type")) + .map(name => name.replace("nz-resizable-handle-", "")) + .join() + ); + + expect(handles).toEqual(["left", "bottom", "bottomLeft"]); + }); + }); });
