mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
595 lines
18 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|