import { screen, within, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi, } from "vitest"; import { renderWithProviders } from "test-utils"; import { ConversationName } from "#/components/features/conversation/conversation-name"; import { ConversationNameContextMenu } from "#/components/features/conversation/conversation-name-context-menu"; import { BrowserRouter } from "react-router"; import type { Conversation } from "#/api/open-hands.types"; // Hoisted mocks for controllable return values const { mockMutate, mockDisplaySuccessToast, useActiveConversationMock, useConfigMock, } = vi.hoisted(() => ({ mockMutate: vi.fn(), mockDisplaySuccessToast: vi.fn(), useActiveConversationMock: vi.fn(() => ({ data: { conversation_id: "test-conversation-id", title: "Test Conversation", status: "RUNNING", }, })), useConfigMock: vi.fn(() => ({ data: { app_mode: "oss", }, })), })); vi.mock("#/hooks/query/use-active-conversation", () => ({ useActiveConversation: () => useActiveConversationMock(), })); vi.mock("#/hooks/query/use-config", () => ({ useConfig: () => useConfigMock(), })); vi.mock("#/hooks/mutation/use-update-conversation", () => ({ useUpdateConversation: () => ({ mutate: mockMutate, }), })); vi.mock("#/utils/custom-toast-handlers", () => ({ displaySuccessToast: mockDisplaySuccessToast, })); // Mock react-i18next vi.mock("react-i18next", async () => { const actual = await vi.importActual("react-i18next"); return { ...actual, useTranslation: () => ({ t: (key: string) => { const translations: Record = { CONVERSATION$TITLE_UPDATED: "Conversation title updated", BUTTON$RENAME: "Rename", BUTTON$EXPORT_CONVERSATION: "Export Conversation", BUTTON$DOWNLOAD_VIA_VSCODE: "Download via VS Code", BUTTON$SHOW_AGENT_TOOLS_AND_METADATA: "Show Agent Tools", CONVERSATION$SHOW_SKILLS: "Show Skills", BUTTON$DISPLAY_COST: "Display Cost", COMMON$CLOSE_CONVERSATION_STOP_RUNTIME: "Close Conversation (Stop Sandbox)", COMMON$DELETE_CONVERSATION: "Delete Conversation", CONVERSATION$SHARE_PUBLICLY: "Share Publicly", CONVERSATION$LINK_COPIED: "Link copied to clipboard", BUTTON$COPY_TO_CLIPBOARD: "Copy to Clipboard", BUTTON$OPEN_IN_NEW_TAB: "Open in New Tab", }; return translations[key] || key; }, i18n: { changeLanguage: () => new Promise(() => {}), }, }), }; }); // Helper function to render ConversationName with Router context const renderConversationNameWithRouter = () => { return renderWithProviders( , ); }; describe("ConversationName", () => { beforeAll(() => { vi.stubGlobal("window", { open: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), location: { origin: "http://localhost:3000", }, }); }); afterEach(() => { vi.resetAllMocks(); }); it("should render the conversation name in view mode", () => { renderConversationNameWithRouter(); const container = screen.getByTestId("conversation-name"); const titleElement = within(container).getByTestId( "conversation-name-title", ); expect(container).toBeInTheDocument(); expect(titleElement).toBeInTheDocument(); expect(titleElement).toHaveTextContent("Test Conversation"); }); it("should switch to edit mode on double click", async () => { const user = userEvent.setup(); renderConversationNameWithRouter(); const titleElement = screen.getByTestId("conversation-name-title"); // Initially should be in view mode expect(titleElement).toBeInTheDocument(); expect( screen.queryByTestId("conversation-name-input"), ).not.toBeInTheDocument(); // Double click to enter edit mode await user.dblClick(titleElement); // Should now be in edit mode expect( screen.queryByTestId("conversation-name-title"), ).not.toBeInTheDocument(); const inputElement = screen.getByTestId("conversation-name-input"); expect(inputElement).toBeInTheDocument(); expect(inputElement).toHaveValue("Test Conversation"); }); it("should update conversation title when input loses focus with valid value", async () => { const user = userEvent.setup(); renderConversationNameWithRouter(); const titleElement = screen.getByTestId("conversation-name-title"); await user.dblClick(titleElement); const inputElement = screen.getByTestId("conversation-name-input"); await user.clear(inputElement); await user.type(inputElement, "New Conversation Title"); await user.tab(); // Trigger blur event // Verify that the update function was called expect(mockMutate).toHaveBeenCalledWith( { conversationId: "test-conversation-id", newTitle: "New Conversation Title", }, expect.any(Object), ); }); it("should not update conversation when title is unchanged", async () => { const user = userEvent.setup(); renderConversationNameWithRouter(); const titleElement = screen.getByTestId("conversation-name-title"); await user.dblClick(titleElement); const inputElement = screen.getByTestId("conversation-name-input"); // Keep the same title await user.tab(); // Should still have the original title expect(inputElement).toHaveValue("Test Conversation"); }); it("should not call the API if user attempts to save an unchanged title", async () => { const user = userEvent.setup(); renderConversationNameWithRouter(); const titleElement = screen.getByTestId("conversation-name-title"); await user.dblClick(titleElement); const inputElement = screen.getByTestId("conversation-name-input"); // Verify the input has the original title expect(inputElement).toHaveValue("Test Conversation"); // Trigger blur without changing the title await user.tab(); // Verify that the API was NOT called expect(mockMutate).not.toHaveBeenCalled(); }); it("should reset input value when title is empty and blur", async () => { const user = userEvent.setup(); renderConversationNameWithRouter(); const titleElement = screen.getByTestId("conversation-name-title"); await user.dblClick(titleElement); const inputElement = screen.getByTestId("conversation-name-input"); await user.clear(inputElement); await user.tab(); // Should reset to original title expect(inputElement).toHaveValue("Test Conversation"); }); it("should trim whitespace from input value", async () => { const user = userEvent.setup(); renderConversationNameWithRouter(); const titleElement = screen.getByTestId("conversation-name-title"); await user.dblClick(titleElement); const inputElement = screen.getByTestId("conversation-name-input"); await user.clear(inputElement); await user.type(inputElement, " Trimmed Title "); await user.tab(); // Should call mutation with trimmed value expect(mockMutate).toHaveBeenCalledWith( { conversationId: "test-conversation-id", newTitle: "Trimmed Title", }, expect.any(Object), ); }); it("should handle Enter key to save changes", async () => { const user = userEvent.setup(); renderConversationNameWithRouter(); const titleElement = screen.getByTestId("conversation-name-title"); await user.dblClick(titleElement); const inputElement = screen.getByTestId("conversation-name-input"); await user.clear(inputElement); await user.type(inputElement, "New Title"); await user.keyboard("{Enter}"); // Should have the new title expect(inputElement).toHaveValue("New Title"); }); it("should prevent event propagation when clicking input in edit mode", async () => { const user = userEvent.setup(); renderConversationNameWithRouter(); const titleElement = screen.getByTestId("conversation-name-title"); await user.dblClick(titleElement); const inputElement = screen.getByTestId("conversation-name-input"); const clickEvent = new MouseEvent("click", { bubbles: true }); const preventDefaultSpy = vi.spyOn(clickEvent, "preventDefault"); const stopPropagationSpy = vi.spyOn(clickEvent, "stopPropagation"); inputElement.dispatchEvent(clickEvent); expect(preventDefaultSpy).toHaveBeenCalled(); expect(stopPropagationSpy).toHaveBeenCalled(); }); it("should return to view mode after blur", async () => { const user = userEvent.setup(); renderConversationNameWithRouter(); const titleElement = screen.getByTestId("conversation-name-title"); await user.dblClick(titleElement); // Should be in edit mode expect(screen.getByTestId("conversation-name-input")).toBeInTheDocument(); await user.tab(); // Should be back in view mode expect(screen.getByTestId("conversation-name-title")).toBeInTheDocument(); expect( screen.queryByTestId("conversation-name-input"), ).not.toBeInTheDocument(); }); it("renders prettified model text and raw model in the tooltip", () => { useActiveConversationMock.mockReturnValue({ data: { conversation_id: "test-conversation-id", title: "Test Conversation", status: "RUNNING", llm_model: "openai/gpt-4o", } as Conversation, }); renderConversationNameWithRouter(); const model = screen.getByTestId("conversation-name-llm-model"); expect(model).toBeInTheDocument(); expect(model).toHaveTextContent("GPT-4o"); expect(model).toHaveAttribute("title", "openai/gpt-4o"); expect(model.querySelector("svg")).toBeInTheDocument(); }); it("should render plain 'ACP' for ACP-agent conversations", () => { useActiveConversationMock.mockReturnValue({ data: { conversation_id: "test-conversation-id", title: "Test Conversation", status: "RUNNING", agent_kind: "acp", } as unknown as Conversation, }); renderConversationNameWithRouter(); const model = screen.getByTestId("conversation-name-llm-model"); expect(model).toHaveTextContent("ACP"); expect(model).toHaveAttribute("title", "ACP"); }); it("should render the provider brand label when acp_server matches a known provider", () => { useActiveConversationMock.mockReturnValue({ data: { conversation_id: "test-conversation-id", title: "Test Conversation", status: "RUNNING", agent_kind: "acp", acp_server: "claude-code", } as unknown as Conversation, }); useConfigMock.mockReturnValue({ data: { app_mode: "oss", acp_providers: [ { key: "claude-code", display_name: "Claude Code", default_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"], }, ], }, } as unknown as ReturnType); renderConversationNameWithRouter(); const model = screen.getByTestId("conversation-name-llm-model"); expect(model).toHaveTextContent("Claude Code"); }); it("renders the underlying model for an ACP conversation that exposes one", () => { useActiveConversationMock.mockReturnValue({ data: { conversation_id: "test-conversation-id", title: "Test Conversation", status: "RUNNING", agent_kind: "acp", acp_server: "claude-code", llm_model: "anthropic/claude-opus-4-1", } as unknown as Conversation, }); useConfigMock.mockReturnValue({ data: { app_mode: "oss", acp_providers: [ { key: "claude-code", display_name: "Claude Code", default_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"], available_models: [ { id: "anthropic/claude-opus-4-1", label: "Claude Opus 4.1" }, ], }, ], }, } as unknown as ReturnType); renderConversationNameWithRouter(); const model = screen.getByTestId("conversation-name-llm-model"); expect(model).toHaveTextContent("Claude Opus 4.1"); expect(model).toHaveAttribute( "title", "Claude Code · anthropic/claude-opus-4-1", ); }); it("falls back to the raw model id for an ACP conversation not in the registry", () => { useActiveConversationMock.mockReturnValue({ data: { conversation_id: "test-conversation-id", title: "Test Conversation", status: "RUNNING", agent_kind: "acp", acp_server: "claude-code", llm_model: "anthropic/claude-opus-4-1", } as unknown as Conversation, }); useConfigMock.mockReturnValue({ data: { app_mode: "oss", acp_providers: [ { key: "claude-code", display_name: "Claude Code", default_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"], }, ], }, } as unknown as ReturnType); renderConversationNameWithRouter(); const model = screen.getByTestId("conversation-name-llm-model"); expect(model).toHaveTextContent("anthropic/claude-opus-4-1"); expect(model).toHaveAttribute( "title", "Claude Code · anthropic/claude-opus-4-1", ); }); it("should not render the llm model when not available", () => { useActiveConversationMock.mockReturnValue({ data: { conversation_id: "test-conversation-id", title: "Test Conversation", status: "RUNNING", }, }); renderConversationNameWithRouter(); expect( screen.queryByTestId("conversation-name-llm-model"), ).not.toBeInTheDocument(); }); it("should focus input when entering edit mode", async () => { const user = userEvent.setup(); renderConversationNameWithRouter(); const titleElement = screen.getByTestId("conversation-name-title"); await user.dblClick(titleElement); const inputElement = screen.getByTestId("conversation-name-input"); expect(inputElement).toHaveFocus(); }); }); describe("ConversationNameContextMenu", () => { const defaultProps = { onClose: vi.fn(), }; afterEach(() => { vi.resetAllMocks(); }); it("should render all menu options when all handlers are provided", () => { const handlers = { onRename: vi.fn(), onDelete: vi.fn(), onStop: vi.fn(), onDisplayCost: vi.fn(), onShowAgentTools: vi.fn(), onShowSkills: vi.fn(), onDownloadConversation: vi.fn(), }; renderWithProviders( , ); expect(screen.getByTestId("rename-button")).toBeInTheDocument(); expect(screen.getByTestId("delete-button")).toBeInTheDocument(); expect(screen.getByTestId("stop-button")).toBeInTheDocument(); expect(screen.getByTestId("display-cost-button")).toBeInTheDocument(); expect(screen.getByTestId("show-agent-tools-button")).toBeInTheDocument(); expect(screen.getByTestId("show-skills-button")).toBeInTheDocument(); expect( screen.getByTestId("download-trajectory-button"), ).toBeInTheDocument(); }); it("should not render menu options when handlers are not provided", () => { renderWithProviders(); expect(screen.queryByTestId("rename-button")).not.toBeInTheDocument(); expect(screen.queryByTestId("delete-button")).not.toBeInTheDocument(); expect(screen.queryByTestId("stop-button")).not.toBeInTheDocument(); expect(screen.queryByTestId("display-cost-button")).not.toBeInTheDocument(); expect( screen.queryByTestId("show-agent-tools-button"), ).not.toBeInTheDocument(); expect(screen.queryByTestId("show-skills-button")).not.toBeInTheDocument(); }); it("should call rename handler when rename button is clicked", async () => { const user = userEvent.setup(); const onRename = vi.fn(); renderWithProviders( , ); const renameButton = screen.getByTestId("rename-button"); await user.click(renameButton); expect(onRename).toHaveBeenCalledTimes(1); }); it("should call delete handler when delete button is clicked", async () => { const user = userEvent.setup(); const onDelete = vi.fn(); renderWithProviders( , ); const deleteButton = screen.getByTestId("delete-button"); await user.click(deleteButton); expect(onDelete).toHaveBeenCalledTimes(1); }); it("should call stop handler when stop button is clicked", async () => { const user = userEvent.setup(); const onStop = vi.fn(); renderWithProviders( , ); const stopButton = screen.getByTestId("stop-button"); await user.click(stopButton); expect(onStop).toHaveBeenCalledTimes(1); }); it("should call display cost handler when display cost button is clicked", async () => { const user = userEvent.setup(); const onDisplayCost = vi.fn(); renderWithProviders( , ); const displayCostButton = screen.getByTestId("display-cost-button"); await user.click(displayCostButton); expect(onDisplayCost).toHaveBeenCalledTimes(1); }); it("should call show agent tools handler when show agent tools button is clicked", async () => { const user = userEvent.setup(); const onShowAgentTools = vi.fn(); renderWithProviders( , ); const showAgentToolsButton = screen.getByTestId("show-agent-tools-button"); await user.click(showAgentToolsButton); expect(onShowAgentTools).toHaveBeenCalledTimes(1); }); it("should call show microagents handler when show microagents button is clicked", async () => { const user = userEvent.setup(); const onShowSkills = vi.fn(); renderWithProviders( , ); const showMicroagentsButton = screen.getByTestId("show-skills-button"); await user.click(showMicroagentsButton); expect(onShowSkills).toHaveBeenCalledTimes(1); }); it("should apply correct positioning class when position is top", () => { const handlers = { onRename: vi.fn(), }; renderWithProviders( , ); const contextMenu = screen.getByTestId("conversation-name-context-menu"); expect(contextMenu).toHaveClass("bottom-full"); }); it("should apply correct positioning class when position is bottom", () => { const handlers = { onRename: vi.fn(), }; renderWithProviders( , ); const contextMenu = screen.getByTestId("conversation-name-context-menu"); expect(contextMenu).toHaveClass("top-full"); }); it("should render correct text content for each menu option", () => { const handlers = { onRename: vi.fn(), onDelete: vi.fn(), onStop: vi.fn(), onDisplayCost: vi.fn(), onShowAgentTools: vi.fn(), onShowSkills: vi.fn(), onDownloadConversation: vi.fn(), }; renderWithProviders( , ); expect(screen.getByTestId("rename-button")).toHaveTextContent("Rename"); expect(screen.getByTestId("delete-button")).toHaveTextContent( "Delete Conversation", ); expect(screen.getByTestId("stop-button")).toHaveTextContent( "Close Conversation (Stop Sandbox)", ); expect(screen.getByTestId("display-cost-button")).toHaveTextContent( "Display Cost", ); expect(screen.getByTestId("show-agent-tools-button")).toHaveTextContent( "Show Agent Tools", ); expect(screen.getByTestId("show-skills-button")).toHaveTextContent( "Show Skills", ); expect(screen.getByTestId("download-trajectory-button")).toHaveTextContent( "Export Conversation", ); }); it("should call onClose when context menu is closed", () => { const onClose = vi.fn(); const handlers = { onRename: vi.fn(), }; renderWithProviders( , ); // The onClose is typically called by the parent component when clicking outside // This test verifies the prop is properly passed expect(onClose).toBeDefined(); }); }); describe("ConversationNameContextMenu - Share Link Functionality", () => { const mockWriteText = vi.fn().mockResolvedValue(undefined); const mockOnCopyShareLink = vi.fn(); const mockOnTogglePublic = vi.fn(); const mockOnClose = vi.fn(); const defaultProps = { onClose: mockOnClose, onTogglePublic: mockOnTogglePublic, onCopyShareLink: mockOnCopyShareLink, shareUrl: "https://example.com/shared/conversations/test-id", }; vi.mock("#/hooks/mutation/use-update-conversation-public-flag", () => ({ useUpdateConversationPublicFlag: () => ({ mutate: vi.fn(), }), })); beforeAll(() => { // Mock navigator.clipboard Object.defineProperty(navigator, "clipboard", { value: { writeText: mockWriteText, readText: vi.fn(), }, writable: true, configurable: true, }); }); beforeEach(() => { mockWriteText.mockClear(); mockDisplaySuccessToast.mockClear(); }); afterEach(() => { vi.resetAllMocks(); }); it("should display copy and open buttons when conversation is public", () => { // Arrange useActiveConversationMock.mockReturnValue({ data: { conversation_id: "test-id", title: "Test Conversation", status: "STOPPED", conversation_version: "V1" as const, public: true, } as Conversation, }); useConfigMock.mockReturnValue({ data: { app_mode: "saas", }, }); // Act renderWithProviders(); // Assert expect(screen.getByTestId("copy-share-link-button")).toBeInTheDocument(); expect(screen.getByTestId("open-share-link-button")).toBeInTheDocument(); }); it("should not display share buttons when conversation is not public", () => { // Arrange useActiveConversationMock.mockReturnValue({ data: { conversation_id: "test-id", title: "Test Conversation", status: "STOPPED", conversation_version: "V1" as const, public: false, } as Conversation, }); useConfigMock.mockReturnValue({ data: { app_mode: "saas", }, }); // Act renderWithProviders(); // Assert expect( screen.queryByTestId("copy-share-link-button"), ).not.toBeInTheDocument(); expect( screen.queryByTestId("open-share-link-button"), ).not.toBeInTheDocument(); }); it("should call copy handler when copy button is clicked", async () => { // Arrange const user = userEvent.setup(); const shareUrl = "https://example.com/shared/conversations/test-id"; useActiveConversationMock.mockReturnValue({ data: { conversation_id: "test-id", title: "Test Conversation", status: "STOPPED", conversation_version: "V1" as const, public: true, } as Conversation, }); useConfigMock.mockReturnValue({ data: { app_mode: "saas", }, }); renderWithProviders( , ); const copyButton = screen.getByTestId("copy-share-link-button"); // Act await user.click(copyButton); // Assert expect(mockOnCopyShareLink).toHaveBeenCalledTimes(1); }); it("should have correct attributes for open share link button", () => { // Arrange const shareUrl = "https://example.com/shared/conversations/test-id"; useActiveConversationMock.mockReturnValue({ data: { conversation_id: "test-id", title: "Test Conversation", status: "STOPPED", conversation_version: "V1" as const, public: true, } as Conversation, }); useConfigMock.mockReturnValue({ data: { app_mode: "saas", }, }); renderWithProviders( , ); const openButton = screen.getByTestId("open-share-link-button"); // Assert expect(openButton).toHaveAttribute("href", shareUrl); expect(openButton).toHaveAttribute("target", "_blank"); expect(openButton).toHaveAttribute("rel", "noopener noreferrer"); }); it("should display correct tooltips for share buttons", () => { // Arrange useActiveConversationMock.mockReturnValue({ data: { conversation_id: "test-id", title: "Test Conversation", status: "STOPPED", conversation_version: "V1" as const, public: true, } as Conversation, }); useConfigMock.mockReturnValue({ data: { app_mode: "saas", }, }); renderWithProviders(); // Assert const copyButton = screen.getByTestId("copy-share-link-button"); const openButton = screen.getByTestId("open-share-link-button"); expect(copyButton).toHaveAttribute("title", "Copy to Clipboard"); expect(openButton).toHaveAttribute("title", "Open in New Tab"); }); describe("Integration with ConversationName component", () => { beforeEach(() => { // Default mocks for public V1 conversation in SAAS mode useActiveConversationMock.mockReturnValue({ data: { conversation_id: "test-conversation-id", title: "Test Conversation", status: "STOPPED", conversation_version: "V1" as const, public: true, } as Conversation, }); useConfigMock.mockReturnValue({ data: { app_mode: "saas", }, }); }); it("should copy share URL to clipboard and show success toast when copy button is clicked through ConversationName", async () => { // Arrange const user = userEvent.setup(); const expectedUrl = "http://localhost:3000/shared/conversations/test-conversation-id"; // Ensure navigator.clipboard is properly mocked if (!navigator.clipboard) { Object.defineProperty(navigator, "clipboard", { value: { writeText: mockWriteText, readText: vi.fn(), }, writable: true, configurable: true, }); } else { vi.spyOn(navigator.clipboard, "writeText").mockImplementation( mockWriteText, ); } renderConversationNameWithRouter(); // Open context menu by clicking ellipsis const ellipsisButton = screen.getByRole("button", { hidden: true }); await user.click(ellipsisButton); // Wait for context menu to appear and find share publicly button const sharePubliclyButton = await screen.findByTestId( "share-publicly-button", ); expect(sharePubliclyButton).toBeInTheDocument(); // Find copy button const copyButton = screen.getByTestId("copy-share-link-button"); // Act await user.click(copyButton); // Assert - clipboard.writeText is async, so we need to wait await waitFor( () => { expect(mockWriteText).toHaveBeenCalledWith(expectedUrl); expect(mockDisplaySuccessToast).toHaveBeenCalledWith( "Link copied to clipboard", ); }, { timeout: 2000, container: document.body }, ); }); it("should show both copy and open buttons when conversation is public", async () => { // Arrange const user = userEvent.setup(); renderConversationNameWithRouter(); // Act - open context menu const ellipsisButton = screen.getByRole("button", { hidden: true }); await user.click(ellipsisButton); // Wait for context menu const sharePubliclyButton = await screen.findByTestId( "share-publicly-button", ); // Assert expect(sharePubliclyButton).toBeInTheDocument(); expect(screen.getByTestId("copy-share-link-button")).toBeInTheDocument(); expect(screen.getByTestId("open-share-link-button")).toBeInTheDocument(); }); }); });