import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, waitFor } from "@testing-library/react"; import React from "react"; import { CopilotKitProvider } from "../../../providers/CopilotKitProvider"; import { CopilotChatConfigurationProvider } from "../../../providers/CopilotChatConfigurationProvider"; import { CopilotChatView } from "../CopilotChatView"; import { LastUserMessageContext } from "../last-user-message-context"; import type { Attachment } from "@copilotkit/shared"; import type { Message } from "@ag-ui/core"; beforeEach(() => { HTMLElement.prototype.scrollTo = vi.fn(); }); const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
{children}
); const sampleMessages = [ { id: "1", role: "user" as const, content: "Hello" }, { id: "2", role: "assistant" as const, content: "Hi there!" }, ]; const sampleAttachments: Attachment[] = [ { id: "att-1", type: "document", source: { type: "url", value: "https://example.com/doc.txt", mimeType: "text/plain", }, filename: "example.txt", size: 42, status: "ready", }, ]; async function waitForMount(screen: { findByTestId: (id: string) => Promise; }) { await screen.findByTestId("copilot-message-list"); } describe("CopilotChatView input overlay layout", () => { it("renders the input inside an absolute-positioned overlay wrapper on the main view", async () => { const screen = render( , ); await waitForMount(screen); // getByTestId throws if missing — presence is implicit. const overlay = screen.getByTestId("copilot-input-overlay"); // Class-level assertion — the cpk: prefix avoids false positives from // consumer classes. Absolute + bottom-0 is the contract we care about. expect(overlay.className).toMatch(/cpk:absolute/); expect(overlay.className).toMatch(/cpk:bottom-0/); // Input (send button) lives inside the overlay, not outside it. const sendButton = screen.getByTestId("copilot-send-button"); expect(overlay.contains(sendButton)).toBe(true); }); it("renders the attachment queue above the input inside the overlay wrapper", async () => { const screen = render( , ); await waitForMount(screen); const overlay = screen.getByTestId("copilot-input-overlay"); const queue = overlay.querySelector( '[data-testid="copilot-attachment-queue"]', ); const sendButton = overlay.querySelector( '[data-testid="copilot-send-button"]', ); expect(queue).not.toBeNull(); expect(sendButton).not.toBeNull(); // DOM order: the attachment queue must appear before the send button // in document order so it renders visually above the pill. const position = queue!.compareDocumentPosition(sendButton!); expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); it("does NOT wrap the welcome-screen input in the overlay", async () => { const screen = render( , ); await screen.findByTestId("copilot-welcome-screen"); // Welcome screen present → no overlay wrapper exists in this render. expect(screen.queryByTestId("copilot-input-overlay")).toBeNull(); }); it("reserves inputContainerHeight as bottom padding on the scroll content", async () => { // Spy on ResizeObserver so we can trigger a known height. The component // uses ResizeObserver to measure the overlay wrapper; we inject a known // value and assert the scroll content's inline padding-bottom reflects it. const callbacks: Array<{ cb: ResizeObserverCallback; target: Element | null; }> = []; const OriginalRO = global.ResizeObserver; class MockResizeObserver { private cb: ResizeObserverCallback; constructor(cb: ResizeObserverCallback) { this.cb = cb; } observe(target: Element) { callbacks.push({ cb: this.cb, target }); } unobserve() {} disconnect() {} } // eslint-disable-next-line @typescript-eslint/no-explicit-any (global as any).ResizeObserver = MockResizeObserver as any; try { const screen = render( , ); await waitForMount(screen); const scrollContent = screen.getByTestId("copilot-scroll-content"); // Simulate the overlay wrapper reporting a content height of 120px. for (const { cb } of callbacks) { cb( [ { contentRect: { height: 120 } as DOMRectReadOnly, } as ResizeObserverEntry, ], {} as ResizeObserver, ); } // After the resize fires, paddingBottom = 120 (input) + 32 (baseline, // no suggestions) = "152px". The test asserts the formula. await waitFor(() => expect(scrollContent.style.paddingBottom).toBe("152px"), ); } finally { // eslint-disable-next-line @typescript-eslint/no-explicit-any (global as any).ResizeObserver = OriginalRO; } }); it("attaches the resize observer when transitioning from welcome to chat view", async () => { // Regression: a `[]`-deps useEffect captured `inputContainerRef.current` // as null when mounted on the welcome screen and never re-ran after the // user sent their first message. The overlay rendered without a measured // height, so paddingBottom stayed at 32 and the last messages slid // underneath the absolute-positioned input pill. Verify the observer // attaches reactively when the overlay mounts post-transition. const callbacks: Array<{ cb: ResizeObserverCallback; target: Element | null; }> = []; const OriginalRO = global.ResizeObserver; class MockResizeObserver { private cb: ResizeObserverCallback; constructor(cb: ResizeObserverCallback) { this.cb = cb; } observe(target: Element) { callbacks.push({ cb: this.cb, target }); } unobserve() {} disconnect() {} } // eslint-disable-next-line @typescript-eslint/no-explicit-any (global as any).ResizeObserver = MockResizeObserver as any; try { // Render with no messages to start on the welcome screen branch — the // overlay wrapper does not exist in this DOM, so the observer cannot // attach yet. const initialMessages: Message[] = []; const screen = render( , ); await screen.findByTestId("copilot-welcome-screen"); expect(screen.queryByTestId("copilot-input-overlay")).toBeNull(); // Transition to the chat view by re-rendering with messages — mirrors // what happens when CopilotChat re-renders after the user submits. screen.rerender( , ); await waitForMount(screen); const overlay = screen.getByTestId("copilot-input-overlay"); // The bug: observer was attached at mount when the overlay element was // null, so it never re-attached after the transition. Verify it now // observes the overlay specifically. await waitFor(() => expect(callbacks.some(({ target }) => target === overlay)).toBe(true), ); const scrollContent = screen.getByTestId("copilot-scroll-content"); // Simulate the overlay reporting a real height (e.g. 88px input pill). // Only fire on the overlay's own observer — other components (e.g. the // textarea autosize) also use ResizeObserver and would corrupt the // assertion if we fed all observers a 88px contentRect. for (const { cb, target } of callbacks) { if (target !== overlay) continue; cb( [ { contentRect: { height: 88 } as DOMRectReadOnly, } as ResizeObserverEntry, ], {} as ResizeObserver, ); } // 88 (input) + 32 (no suggestions baseline) = 120px. Without the fix, // paddingBottom would be stuck at 32px because the observer never // attached. await waitFor(() => expect(scrollContent.style.paddingBottom).toBe("120px"), ); } finally { // eslint-disable-next-line @typescript-eslint/no-explicit-any (global as any).ResizeObserver = OriginalRO; } }); });