Files
OpenHands/frontend/__tests__/hooks/use-draft-persistence.test.tsx

595 lines
18 KiB
TypeScript

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 getTextContent utility
vi.mock("#/components/features/chat/utils/chat-input.utils", () => ({
getTextContent: vi.fn((el: HTMLDivElement | null) => el?.textContent || ""),
}));
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: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: null,
},
setSelectedTab: vi.fn(),
setRightPanelShown: vi.fn(),
setUnpinnedTabs: vi.fn(),
setConversationMode: vi.fn(),
setDraftMessage: mockSetDraftMessage,
});
// Default mock for getConversationState
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
selectedTab: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: null,
});
});
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: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: savedDraft,
});
// 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: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: savedDraft,
});
// 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: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: null,
});
// 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: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: existingDraft,
},
setSelectedTab: vi.fn(),
setRightPanelShown: vi.fn(),
setUnpinnedTabs: vi.fn(),
setConversationMode: vi.fn(),
setDraftMessage: mockSetDraftMessage,
});
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
selectedTab: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: existingDraft,
});
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: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: "Draft from conv A",
})
.mockReturnValue({
selectedTab: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: null,
});
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: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: null,
})
.mockReturnValue({
selectedTab: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: draftForConvB,
});
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: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: null,
});
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: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: null,
});
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: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: null,
});
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: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: "Existing draft",
},
setSelectedTab: vi.fn(),
setRightPanelShown: vi.fn(),
setUnpinnedTabs: vi.fn(),
setConversationMode: vi.fn(),
setDraftMessage: mockSetDraftMessage,
});
// 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: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: "Draft to restore",
});
// 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();
});
});
});