import { defineComponent, h, ref } from "vue"; import { render, screen, fireEvent, waitFor, within, } from "@testing-library/vue"; import { beforeEach, describe, expect, it, vi } from "vitest"; import CopilotChatConfigurationProvider from "../../../providers/CopilotChatConfigurationProvider.vue"; import CopilotChatInput from "../CopilotChatInput.vue"; const TEST_THREAD_ID = "test-thread"; const mockOnSubmitMessage = vi.fn(); const getSendButton = () => screen.getByTestId("copilot-chat-input-send") as HTMLButtonElement; const getAddMenuButton = () => screen.getByTestId("copilot-chat-input-add") as HTMLButtonElement; const rectFactory = (width: number) => () => ({ width, height: 40, top: 0, left: 0, right: width, bottom: 40, x: 0, y: 0, toJSON: () => ({}), }); const mockLayoutMetrics = ( container: HTMLElement, options?: { gridWidth?: number; addWidth?: number; actionsWidth?: number }, ) => { const grid = container.querySelector("div.cpk\\:grid") as HTMLElement | null; if (!grid) { throw new Error("Grid container not found in CopilotChatInput layout"); } const { gridWidth = 640, addWidth = 48, actionsWidth = 96 } = options ?? {}; Object.defineProperty(grid, "clientWidth", { value: gridWidth, configurable: true, }); const addContainer = grid.children[0] as HTMLElement; const actionsContainer = grid.children[2] as HTMLElement; Object.defineProperty(addContainer, "getBoundingClientRect", { value: rectFactory(addWidth), configurable: true, }); Object.defineProperty(actionsContainer, "getBoundingClientRect", { value: rectFactory(actionsWidth), configurable: true, }); }; Object.defineProperty(HTMLTextAreaElement.prototype, "scrollHeight", { configurable: true, get: function (this: HTMLTextAreaElement) { const text = this.value || ""; const explicitLines = text.split("\n").length; let wrappedLines = 0; text.split("\n").forEach((line) => { const lineWraps = Math.ceil(line.length / 50); wrappedLines += Math.max(1, lineWraps); }); const totalLines = Math.max(explicitLines, wrappedLines); return totalLines * 24; }, }); function renderWithProvider(args?: { props?: Record; listeners?: Record unknown>; template?: string; }) { if (args?.template) { const Host = defineComponent({ components: { CopilotChatConfigurationProvider, CopilotChatInput, }, setup() { return { TEST_THREAD_ID, inputProps: args.props ?? {}, listeners: args.listeners ?? {}, }; }, template: args.template, }); return render(Host); } const Host = defineComponent({ setup() { return { inputProps: args?.props ?? {}, listeners: args?.listeners ?? {}, }; }, render() { return h( CopilotChatConfigurationProvider, { threadId: TEST_THREAD_ID }, { default: () => h(CopilotChatInput, { ...this.inputProps, ...this.listeners }), }, ); }, }); return render(Host); } beforeEach(() => { mockOnSubmitMessage.mockClear(); vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation( () => ({ measureText: (text: string) => ({ width: text.length * 8 }), font: "", }) as unknown as CanvasRenderingContext2D, ); }); describe("CopilotChatInput", () => { it("renders with default components and styling", () => { const onUpdateModelValue = vi.fn(); renderWithProvider({ props: { modelValue: "" }, listeners: { "onUpdate:modelValue": onUpdateModelValue, onSubmitMessage: mockOnSubmitMessage, }, }); const input = screen.getByPlaceholderText("Type a message..."); const sendButton = getSendButton(); expect(input).toBeDefined(); expect(sendButton).not.toBeNull(); expect(sendButton.disabled).toBe(true); }); it("calls onSubmitMessage with trimmed text when Enter is pressed", async () => { const onUpdateModelValue = vi.fn(); renderWithProvider({ props: { modelValue: " hello world " }, listeners: { "onUpdate:modelValue": onUpdateModelValue, onSubmitMessage: mockOnSubmitMessage, }, }); const input = screen.getByPlaceholderText("Type a message..."); await fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); expect(mockOnSubmitMessage).toHaveBeenCalledWith("hello world"); }); it("calls onSubmitMessage when button is clicked", async () => { const onUpdateModelValue = vi.fn(); renderWithProvider({ props: { modelValue: "test message" }, listeners: { "onUpdate:modelValue": onUpdateModelValue, onSubmitMessage: mockOnSubmitMessage, }, }); const sendButton = getSendButton(); expect(sendButton).not.toBeNull(); await fireEvent.click(sendButton); expect(mockOnSubmitMessage).toHaveBeenCalledWith("test message"); }); it("manages text state internally when uncontrolled", async () => { renderWithProvider({ listeners: { onSubmitMessage: mockOnSubmitMessage }, }); const input = screen.getByPlaceholderText("Type a message..."); const sendButton = getSendButton(); await fireEvent.input(input, { target: { value: "hello" } }); await fireEvent.click(sendButton); expect(mockOnSubmitMessage).toHaveBeenCalledWith("hello"); expect((input as HTMLTextAreaElement).value).toBe(""); }); it("does not send when Enter is pressed with Shift key", async () => { const onUpdateModelValue = vi.fn(); renderWithProvider({ props: { modelValue: "test" }, listeners: { "onUpdate:modelValue": onUpdateModelValue, onSubmitMessage: mockOnSubmitMessage, }, }); const input = screen.getByPlaceholderText("Type a message..."); await fireEvent.keyDown(input, { key: "Enter", shiftKey: true }); expect(mockOnSubmitMessage).not.toHaveBeenCalled(); }); it("does not send empty or whitespace-only messages", async () => { const onUpdateModelValue = vi.fn(); const first = renderWithProvider({ props: { modelValue: "" }, listeners: { "onUpdate:modelValue": onUpdateModelValue, onSubmitMessage: mockOnSubmitMessage, }, }); await fireEvent.click(getSendButton()); expect(mockOnSubmitMessage).not.toHaveBeenCalled(); first.unmount(); renderWithProvider({ props: { modelValue: " " }, listeners: { "onUpdate:modelValue": onUpdateModelValue, onSubmitMessage: mockOnSubmitMessage, }, }); await fireEvent.click(getSendButton()); expect(mockOnSubmitMessage).not.toHaveBeenCalled(); }); it("keeps input value when no submit handler is provided", async () => { renderWithProvider(); const input = screen.getByPlaceholderText("Type a message..."); await fireEvent.input(input, { target: { value: "draft" } }); await fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); expect((input as HTMLTextAreaElement).value).toBe("draft"); }); it("enables button based on value prop", async () => { const onUpdateModelValue = vi.fn(); const first = renderWithProvider({ props: { modelValue: "" }, listeners: { "onUpdate:modelValue": onUpdateModelValue, onSubmitMessage: mockOnSubmitMessage, }, }); expect(getSendButton().disabled).toBe(true); first.unmount(); const second = renderWithProvider({ props: { modelValue: "hello" }, listeners: { "onUpdate:modelValue": onUpdateModelValue, onSubmitMessage: mockOnSubmitMessage, }, }); expect(getSendButton().disabled).toBe(false); second.unmount(); renderWithProvider({ props: { modelValue: "" }, listeners: { "onUpdate:modelValue": onUpdateModelValue, onSubmitMessage: mockOnSubmitMessage, }, }); expect(getSendButton().disabled).toBe(true); }); it("accepts custom slot classes", () => { const Host = defineComponent({ components: { CopilotChatConfigurationProvider, CopilotChatInput }, setup() { return { TEST_THREAD_ID }; }, template: `