Download __tests__/hooks/use-draft-persistence.test.tsx from SaylorTwift/openhands: direct link, hf CLI and curl.
- Browser
- Download file 19.9 kB
-
https://huggingface.co/SaylorTwift/openhands/resolve/main/__tests__/hooks/use-draft-persistence.test.tsx
- Command line
-
hf download hf://SaylorTwift/openhands/__tests__/hooks/use-draft-persistence.test.tsx
-
curl -L -o use-draft-persistence.test.tsx https://huggingface.co/SaylorTwift/openhands/resolve/main/__tests__/hooks/use-draft-persistence.test.tsx
19.9 kB
| import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; | |
| import { renderHook, act } from "@testing-library/react"; | |
| import { useDraftPersistence } from "#/hooks/chat/use-draft-persistence"; | |
| import * as conversationLocalStorage from "#/utils/conversation-local-storage"; | |
| // Mock the entire module | |
| vi.mock("#/utils/conversation-local-storage", () => ({ | |
| useConversationLocalStorageState: vi.fn(), | |
| getConversationState: vi.fn(), | |
| setConversationState: vi.fn(), | |
| })); | |
| // Mock the chat-input utilities. The hook uses both `getTextContent` | |
| // (to read the current contentEditable text) and `focusContentEditableAtEnd` | |
| // (to place the caret at the end of the input after restoring a draft). | |
| // Both need to be mocked here so the hook doesn't crash on | |
| // `focusContentEditableAtEnd is not a function`. | |
| vi.mock("#/components/features/chat/utils/chat-input.utils", () => ({ | |
| getTextContent: vi.fn((el: HTMLDivElement | null) => el?.textContent || ""), | |
| focusContentEditableAtEnd: vi.fn(), | |
| })); | |
| describe("useDraftPersistence", () => { | |
| let mockSetDraftMessage: (message: string | null) => void; | |
| // Create a mock ref to contentEditable div | |
| const createMockChatInputRef = (initialContent = "") => { | |
| const div = document.createElement("div"); | |
| div.setAttribute("contenteditable", "true"); | |
| div.textContent = initialContent; | |
| return { current: div }; | |
| }; | |
| beforeEach(() => { | |
| vi.clearAllMocks(); | |
| vi.useFakeTimers(); | |
| localStorage.clear(); | |
| mockSetDraftMessage = vi.fn<(message: string | null) => void>(); | |
| // Default mock for useConversationLocalStorageState | |
| vi.mocked(conversationLocalStorage.useConversationLocalStorageState).mockReturnValue({ | |
| state: { | |
| selectedTab: "files", | |
| unpinnedTabs: [], | |
| conversationMode: "code", | |
| subConversationTaskId: null, | |
| draftMessage: null, | |
| filesTabDiffView: null, | |
| filesTabContentViewMode: "rich", | |
| }, | |
| setSelectedTab: vi.fn(), | |
| setUnpinnedTabs: vi.fn(), | |
| setConversationMode: vi.fn(), | |
| setDraftMessage: mockSetDraftMessage, | |
| setFilesTabDiffView: vi.fn(), | |
| setFilesTabContentViewMode: vi.fn(), | |
| }); | |
| // Default mock for getConversationState | |
| vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ | |
| selectedTab: "files", | |
| unpinnedTabs: [], | |
| conversationMode: "code", | |
| subConversationTaskId: null, | |
| draftMessage: null, | |
| filesTabDiffView: null, | |
| filesTabContentViewMode: "rich", | |
| }); | |
| }); | |
| afterEach(() => { | |
| vi.useRealTimers(); | |
| vi.clearAllMocks(); | |
| }); | |
| describe("draft restoration on mount", () => { | |
| it("restores draft from localStorage when mounting with existing draft", () => { | |
| // Arrange | |
| const conversationId = "conv-restore-1"; | |
| const savedDraft = "Previously saved draft message"; | |
| const chatInputRef = createMockChatInputRef(); | |
| vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ | |
| selectedTab: "files", | |
| unpinnedTabs: [], | |
| conversationMode: "code", | |
| subConversationTaskId: null, | |
| draftMessage: savedDraft, | |
| filesTabDiffView: null, | |
| filesTabContentViewMode: "rich", | |
| }); | |
| // Act | |
| renderHook(() => useDraftPersistence(conversationId, chatInputRef)); | |
| // Assert - draft should be restored to the DOM element | |
| expect(chatInputRef.current?.textContent).toBe(savedDraft); | |
| }); | |
| it("clears input on mount then restores draft if exists", () => { | |
| // Arrange | |
| const conversationId = "conv-restore-2"; | |
| const existingContent = "Stale content from previous conversation"; | |
| const savedDraft = "Saved draft"; | |
| const chatInputRef = createMockChatInputRef(existingContent); | |
| vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ | |
| selectedTab: "files", | |
| unpinnedTabs: [], | |
| conversationMode: "code", | |
| subConversationTaskId: null, | |
| draftMessage: savedDraft, | |
| filesTabDiffView: null, | |
| filesTabContentViewMode: "rich", | |
| }); | |
| // Act | |
| renderHook(() => useDraftPersistence(conversationId, chatInputRef)); | |
| // Assert - input cleared then draft restored | |
| expect(chatInputRef.current?.textContent).toBe(savedDraft); | |
| }); | |
| it("clears input when no draft exists for conversation", () => { | |
| // Arrange | |
| const conversationId = "conv-no-draft"; | |
| const chatInputRef = createMockChatInputRef("Some stale content"); | |
| vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ | |
| selectedTab: "files", | |
| unpinnedTabs: [], | |
| conversationMode: "code", | |
| subConversationTaskId: null, | |
| draftMessage: null, | |
| filesTabDiffView: null, | |
| filesTabContentViewMode: "rich", | |
| }); | |
| // Act | |
| renderHook(() => useDraftPersistence(conversationId, chatInputRef)); | |
| // Assert - content should be cleared since there's no draft | |
| expect(chatInputRef.current?.textContent).toBe(""); | |
| }); | |
| }); | |
| describe("debounced saving", () => { | |
| it("saves draft after debounce period", () => { | |
| // Arrange | |
| const conversationId = "conv-debounce-1"; | |
| const chatInputRef = createMockChatInputRef(); | |
| const { result } = renderHook(() => | |
| useDraftPersistence(conversationId, chatInputRef), | |
| ); | |
| // Act - simulate user typing | |
| chatInputRef.current!.textContent = "New draft content"; | |
| act(() => { | |
| result.current.saveDraft(); | |
| }); | |
| // Assert - should not save immediately | |
| expect(mockSetDraftMessage).not.toHaveBeenCalled(); | |
| // Fast forward past debounce period (500ms) | |
| act(() => { | |
| vi.advanceTimersByTime(500); | |
| }); | |
| // Assert - should save after debounce | |
| expect(mockSetDraftMessage).toHaveBeenCalledWith("New draft content"); | |
| }); | |
| it("cancels pending save when new input arrives before debounce", () => { | |
| // Arrange | |
| const conversationId = "conv-debounce-2"; | |
| const chatInputRef = createMockChatInputRef(); | |
| const { result } = renderHook(() => | |
| useDraftPersistence(conversationId, chatInputRef), | |
| ); | |
| // Act - first input | |
| chatInputRef.current!.textContent = "First"; | |
| act(() => { | |
| result.current.saveDraft(); | |
| }); | |
| // Wait 200ms (less than debounce) | |
| act(() => { | |
| vi.advanceTimersByTime(200); | |
| }); | |
| // Second input before debounce completes | |
| chatInputRef.current!.textContent = "First Second"; | |
| act(() => { | |
| result.current.saveDraft(); | |
| }); | |
| // Complete the second debounce | |
| act(() => { | |
| vi.advanceTimersByTime(500); | |
| }); | |
| // Assert - should only save the final value once | |
| expect(mockSetDraftMessage).toHaveBeenCalledTimes(1); | |
| expect(mockSetDraftMessage).toHaveBeenCalledWith("First Second"); | |
| }); | |
| it("does not save if content matches existing draft", () => { | |
| // Arrange | |
| const conversationId = "conv-no-change"; | |
| const existingDraft = "Existing draft"; | |
| const chatInputRef = createMockChatInputRef(existingDraft); | |
| vi.mocked(conversationLocalStorage.useConversationLocalStorageState).mockReturnValue({ | |
| state: { | |
| selectedTab: "files", | |
| unpinnedTabs: [], | |
| conversationMode: "code", | |
| subConversationTaskId: null, | |
| draftMessage: existingDraft, | |
| filesTabDiffView: null, | |
| filesTabContentViewMode: "rich", | |
| }, | |
| setSelectedTab: vi.fn(), | |
| setUnpinnedTabs: vi.fn(), | |
| setConversationMode: vi.fn(), | |
| setDraftMessage: mockSetDraftMessage, | |
| setFilesTabDiffView: vi.fn(), | |
| setFilesTabContentViewMode: vi.fn(), | |
| }); | |
| vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ | |
| selectedTab: "files", | |
| unpinnedTabs: [], | |
| conversationMode: "code", | |
| subConversationTaskId: null, | |
| draftMessage: existingDraft, | |
| filesTabDiffView: null, | |
| filesTabContentViewMode: "rich", | |
| }); | |
| const { result } = renderHook(() => | |
| useDraftPersistence(conversationId, chatInputRef), | |
| ); | |
| // Act - try to save same content | |
| act(() => { | |
| result.current.saveDraft(); | |
| }); | |
| act(() => { | |
| vi.advanceTimersByTime(500); | |
| }); | |
| // Assert - should not save since content is the same | |
| expect(mockSetDraftMessage).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| describe("clearDraft", () => { | |
| it("clears the draft from localStorage", () => { | |
| // Arrange | |
| const conversationId = "conv-clear-1"; | |
| const chatInputRef = createMockChatInputRef("Some content"); | |
| const { result } = renderHook(() => | |
| useDraftPersistence(conversationId, chatInputRef), | |
| ); | |
| // Act | |
| act(() => { | |
| result.current.clearDraft(); | |
| }); | |
| // Assert | |
| expect(mockSetDraftMessage).toHaveBeenCalledWith(null); | |
| }); | |
| it("cancels any pending debounced save when clearing", () => { | |
| // Arrange | |
| const conversationId = "conv-clear-2"; | |
| const chatInputRef = createMockChatInputRef(); | |
| const { result } = renderHook(() => | |
| useDraftPersistence(conversationId, chatInputRef), | |
| ); | |
| // Start a save | |
| chatInputRef.current!.textContent = "Pending draft"; | |
| act(() => { | |
| result.current.saveDraft(); | |
| }); | |
| // Clear before debounce completes | |
| act(() => { | |
| vi.advanceTimersByTime(200); | |
| result.current.clearDraft(); | |
| }); | |
| // Complete the original debounce period | |
| act(() => { | |
| vi.advanceTimersByTime(500); | |
| }); | |
| // Assert - only the clear should have been called (the pending save should be cancelled) | |
| expect(mockSetDraftMessage).toHaveBeenCalledTimes(1); | |
| expect(mockSetDraftMessage).toHaveBeenCalledWith(null); | |
| }); | |
| }); | |
| describe("conversation switching", () => { | |
| it("clears input when switching to a new conversation without a draft", () => { | |
| // Arrange | |
| const chatInputRef = createMockChatInputRef("Draft from conv A"); | |
| // First conversation has a draft | |
| vi.mocked(conversationLocalStorage.getConversationState) | |
| .mockReturnValueOnce({ | |
| selectedTab: "files", | |
| unpinnedTabs: [], | |
| conversationMode: "code", | |
| subConversationTaskId: null, | |
| draftMessage: "Draft from conv A", | |
| filesTabDiffView: null, | |
| filesTabContentViewMode: "rich", | |
| }) | |
| .mockReturnValue({ | |
| selectedTab: "files", | |
| unpinnedTabs: [], | |
| conversationMode: "code", | |
| subConversationTaskId: null, | |
| draftMessage: null, | |
| filesTabDiffView: null, | |
| filesTabContentViewMode: "rich", | |
| }); | |
| const { rerender } = renderHook( | |
| ({ conversationId }) => | |
| useDraftPersistence(conversationId, chatInputRef), | |
| { initialProps: { conversationId: "conv-A" } }, | |
| ); | |
| // Act - switch to conversation B | |
| rerender({ conversationId: "conv-B" }); | |
| // Assert - input should be cleared (no draft for conv-B) | |
| expect(chatInputRef.current?.textContent).toBe(""); | |
| }); | |
| it("restores draft when switching to a conversation with an existing draft", () => { | |
| // Arrange | |
| const chatInputRef = createMockChatInputRef(); | |
| const draftForConvB = "Saved draft for conversation B"; | |
| vi.mocked(conversationLocalStorage.getConversationState) | |
| .mockReturnValueOnce({ | |
| selectedTab: "files", | |
| unpinnedTabs: [], | |
| conversationMode: "code", | |
| subConversationTaskId: null, | |
| draftMessage: null, | |
| filesTabDiffView: null, | |
| filesTabContentViewMode: "rich", | |
| }) | |
| .mockReturnValue({ | |
| selectedTab: "files", | |
| unpinnedTabs: [], | |
| conversationMode: "code", | |
| subConversationTaskId: null, | |
| draftMessage: draftForConvB, | |
| filesTabDiffView: null, | |
| filesTabContentViewMode: "rich", | |
| }); | |
| const { rerender } = renderHook( | |
| ({ conversationId }) => | |
| useDraftPersistence(conversationId, chatInputRef), | |
| { initialProps: { conversationId: "conv-A" } }, | |
| ); | |
| // Act - switch to conversation B | |
| rerender({ conversationId: "conv-B" }); | |
| // Assert - draft for conv-B should be restored | |
| expect(chatInputRef.current?.textContent).toBe(draftForConvB); | |
| }); | |
| it("cancels pending save when switching conversations", () => { | |
| // Arrange | |
| const chatInputRef = createMockChatInputRef(); | |
| const { result, rerender } = renderHook( | |
| ({ conversationId }) => | |
| useDraftPersistence(conversationId, chatInputRef), | |
| { initialProps: { conversationId: "conv-A" } }, | |
| ); | |
| // Start typing in conv-A | |
| chatInputRef.current!.textContent = "Draft for conv-A"; | |
| act(() => { | |
| result.current.saveDraft(); | |
| }); | |
| // Switch conversation before debounce completes | |
| act(() => { | |
| vi.advanceTimersByTime(200); | |
| }); | |
| rerender({ conversationId: "conv-B" }); | |
| // Complete the debounce period | |
| act(() => { | |
| vi.advanceTimersByTime(500); | |
| }); | |
| // Assert - the save should NOT have happened because conversation changed | |
| expect(mockSetDraftMessage).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| describe("task ID to real conversation ID transition", () => { | |
| it("transfers draft from task ID to real conversation ID during transition", () => { | |
| // Arrange | |
| const chatInputRef = createMockChatInputRef("Draft typed during init"); | |
| vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ | |
| selectedTab: "files", | |
| unpinnedTabs: [], | |
| conversationMode: "code", | |
| subConversationTaskId: null, | |
| draftMessage: null, | |
| filesTabDiffView: null, | |
| filesTabContentViewMode: "rich", | |
| }); | |
| const { rerender } = renderHook( | |
| ({ conversationId }) => | |
| useDraftPersistence(conversationId, chatInputRef), | |
| { initialProps: { conversationId: "task-abc-123" } }, | |
| ); | |
| // Simulate user typing during task initialization | |
| chatInputRef.current!.textContent = "Draft typed during init"; | |
| // Act - transition to real conversation ID | |
| rerender({ conversationId: "conv-real-123" }); | |
| // Assert - draft should be saved to the new real conversation ID | |
| expect(conversationLocalStorage.setConversationState).toHaveBeenCalledWith( | |
| "conv-real-123", | |
| { draftMessage: "Draft typed during init" }, | |
| ); | |
| // And the draft should remain visible in the input | |
| expect(chatInputRef.current?.textContent).toBe("Draft typed during init"); | |
| }); | |
| it("does not transfer empty draft during task-to-real transition", () => { | |
| // Arrange | |
| const chatInputRef = createMockChatInputRef(""); | |
| vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ | |
| selectedTab: "files", | |
| unpinnedTabs: [], | |
| conversationMode: "code", | |
| subConversationTaskId: null, | |
| draftMessage: null, | |
| filesTabDiffView: null, | |
| filesTabContentViewMode: "rich", | |
| }); | |
| const { rerender } = renderHook( | |
| ({ conversationId }) => | |
| useDraftPersistence(conversationId, chatInputRef), | |
| { initialProps: { conversationId: "task-abc-123" } }, | |
| ); | |
| // Act - transition to real conversation ID with empty input | |
| rerender({ conversationId: "conv-real-123" }); | |
| // Assert - no draft should be saved (input is cleared, checked by hook) | |
| // The setConversationState should not be called with draftMessage | |
| expect(conversationLocalStorage.setConversationState).not.toHaveBeenCalled(); | |
| }); | |
| it("does not transfer draft for non-task ID transitions", () => { | |
| // Arrange | |
| const chatInputRef = createMockChatInputRef("Some draft"); | |
| vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ | |
| selectedTab: "files", | |
| unpinnedTabs: [], | |
| conversationMode: "code", | |
| subConversationTaskId: null, | |
| draftMessage: null, | |
| filesTabDiffView: null, | |
| filesTabContentViewMode: "rich", | |
| }); | |
| const { rerender } = renderHook( | |
| ({ conversationId }) => | |
| useDraftPersistence(conversationId, chatInputRef), | |
| { initialProps: { conversationId: "conv-A" } }, | |
| ); | |
| // Act - normal conversation switch (not task-to-real) | |
| rerender({ conversationId: "conv-B" }); | |
| // Assert - should not use setConversationState directly | |
| // (the normal path uses setDraftMessage from the hook) | |
| expect(conversationLocalStorage.setConversationState).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| describe("hasDraft and isRestored state", () => { | |
| it("returns hasDraft true when draft exists in hook state", () => { | |
| // Arrange | |
| const conversationId = "conv-has-draft"; | |
| const chatInputRef = createMockChatInputRef(); | |
| vi.mocked(conversationLocalStorage.useConversationLocalStorageState).mockReturnValue({ | |
| state: { | |
| selectedTab: "files", | |
| unpinnedTabs: [], | |
| conversationMode: "code", | |
| subConversationTaskId: null, | |
| draftMessage: "Existing draft", | |
| filesTabDiffView: null, | |
| filesTabContentViewMode: "rich", | |
| }, | |
| setSelectedTab: vi.fn(), | |
| setUnpinnedTabs: vi.fn(), | |
| setConversationMode: vi.fn(), | |
| setDraftMessage: mockSetDraftMessage, | |
| setFilesTabDiffView: vi.fn(), | |
| setFilesTabContentViewMode: vi.fn(), | |
| }); | |
| // Act | |
| const { result } = renderHook(() => | |
| useDraftPersistence(conversationId, chatInputRef), | |
| ); | |
| // Assert | |
| expect(result.current.hasDraft).toBe(true); | |
| }); | |
| it("returns hasDraft false when no draft exists", () => { | |
| // Arrange | |
| const conversationId = "conv-no-draft"; | |
| const chatInputRef = createMockChatInputRef(); | |
| // Act | |
| const { result } = renderHook(() => | |
| useDraftPersistence(conversationId, chatInputRef), | |
| ); | |
| // Assert | |
| expect(result.current.hasDraft).toBe(false); | |
| }); | |
| it("sets isRestored to true after restoration completes", () => { | |
| // Arrange | |
| const conversationId = "conv-restored"; | |
| const chatInputRef = createMockChatInputRef(); | |
| vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ | |
| selectedTab: "files", | |
| unpinnedTabs: [], | |
| conversationMode: "code", | |
| subConversationTaskId: null, | |
| draftMessage: "Draft to restore", | |
| filesTabDiffView: null, | |
| filesTabContentViewMode: "rich", | |
| }); | |
| // Act | |
| const { result } = renderHook(() => | |
| useDraftPersistence(conversationId, chatInputRef), | |
| ); | |
| // Assert | |
| expect(result.current.isRestored).toBe(true); | |
| }); | |
| }); | |
| describe("cleanup on unmount", () => { | |
| it("clears pending timeout on unmount", () => { | |
| // Arrange | |
| const conversationId = "conv-unmount"; | |
| const chatInputRef = createMockChatInputRef(); | |
| const { result, unmount } = renderHook(() => | |
| useDraftPersistence(conversationId, chatInputRef), | |
| ); | |
| // Start a save | |
| chatInputRef.current!.textContent = "Draft"; | |
| act(() => { | |
| result.current.saveDraft(); | |
| }); | |
| // Unmount before debounce completes | |
| unmount(); | |
| // Complete the debounce period | |
| act(() => { | |
| vi.advanceTimersByTime(500); | |
| }); | |
| // Assert - save should not have been called after unmount | |
| expect(mockSetDraftMessage).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| }); | |