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"]);
+    });
+  });
 });

Reply via email to