import { describe, expect, it, vi, beforeEach } from "vitest";
import { screen } from "@testing-library/react";
import { QueryClient } from "@tanstack/react-query";
import { MemoryRouter, Route, Routes } from "react-router";
import { render } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { useParamsMock, createUserMessageEvent } from "test-utils";
import { ChatInterface } from "#/components/features/chat/chat-interface";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
import { useConfig } from "#/hooks/query/use-config";
import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files";
import { useEventStore } from "#/stores/use-event-store";
import { useAgentState } from "#/hooks/use-agent-state";
import { AgentState } from "#/types/agent-state";
import { OpenHandsAction } from "#/types/core/actions";
// Module-level mocks
vi.mock("#/context/ws-client-provider");
vi.mock("#/hooks/query/use-config");
vi.mock("#/hooks/mutation/use-unified-upload-files");
vi.mock("#/hooks/use-conversation-id");
vi.mock("#/hooks/query/use-active-conversation");
vi.mock("#/contexts/conversation-websocket-context");
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => ({
providers: [],
}),
}));
vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
useConversationNameContextMenu: () => ({
isOpen: false,
contextMenuRef: { current: null },
handleContextMenu: vi.fn(),
handleClose: vi.fn(),
handleRename: vi.fn(),
handleDelete: vi.fn(),
}),
}));
vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(() => ({
curAgentState: AgentState.AWAITING_USER_INPUT, isArchived: false,
})),
}));
// Helper to render with QueryClient and route params
const renderWithQueryClient = (
ui: React.ReactElement,
queryClient: QueryClient,
route = "/test-conversation-id",
) =>
render(
,
);
// V0 user event (numeric id, action property)
const createV0UserEvent = (): OpenHandsAction => ({
id: 1,
source: "user",
action: "message",
args: {
content: "Hello from V0",
image_urls: [],
file_urls: [],
},
message: "Hello from V0",
timestamp: "2025-07-01T00:00:00Z",
});
describe("ChatInterface – message display continuity (spec 3.1)", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
useParamsMock.mockReturnValue({ conversationId: "test-conversation-id" });
vi.mocked(useConversationId).mockReturnValue({
conversationId: "test-conversation-id",
});
(useConfig as unknown as ReturnType).mockReturnValue({
data: { app_mode: "local" },
});
(
useUnifiedUploadFiles as unknown as ReturnType
).mockReturnValue({
mutateAsync: vi
.fn()
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
isLoading: false,
});
// Default: no conversation (V0 behavior)
vi.mocked(useActiveConversation).mockReturnValue({
data: undefined,
} as ReturnType);
// Default: no websocket context
vi.mocked(useConversationWebSocket).mockReturnValue(null);
});
describe("V1 conversations", () => {
beforeEach(() => {
// Set up V1 conversation
vi.mocked(useActiveConversation).mockReturnValue({
data: {},
} as ReturnType);
});
it("shows messages immediately when V1 events exist in store, even while loading", () => {
// Simulate: history is loading but events already exist in store (e.g., remount)
vi.mocked(useConversationWebSocket).mockReturnValue({
isLoadingHistory: true,
connectionState: "OPEN",
sendMessage: vi.fn(),
});
// Put V1 user events in the store
const v1UserEvent = createUserMessageEvent("evt-1");
useEventStore.setState({
events: [v1UserEvent],
uiEvents: [v1UserEvent],
});
renderWithQueryClient(, queryClient);
// AC1: Messages should display immediately without skeleton
expect(
screen.queryByTestId("chat-messages-skeleton"),
).not.toBeInTheDocument();
expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
});
it("shows skeleton when store is empty and loading", () => {
// Simulate: first load, no events yet
vi.mocked(useConversationWebSocket).mockReturnValue({
isLoadingHistory: true,
connectionState: "OPEN",
sendMessage: vi.fn(),
});
// Store is empty
useEventStore.setState({
events: [],
uiEvents: [],
});
renderWithQueryClient(, queryClient);
// AC5: Genuine first-load shows skeleton
expect(screen.getByTestId("chat-messages-skeleton")).toBeInTheDocument();
});
it("shows messages when loading is already false on mount (edge case)", () => {
// Simulate: component re-mounts when WebSocket has already finished loading
vi.mocked(useConversationWebSocket).mockReturnValue({
isLoadingHistory: false,
connectionState: "OPEN",
sendMessage: vi.fn(),
});
// V1 events in store
const v1UserEvent = createUserMessageEvent("evt-2");
useEventStore.setState({
events: [v1UserEvent],
uiEvents: [v1UserEvent],
});
renderWithQueryClient(, queryClient);
// Messages should display, no skeleton
expect(
screen.queryByTestId("chat-messages-skeleton"),
).not.toBeInTheDocument();
expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
});
});
});