All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
284 lines
9.0 KiB
TypeScript
284 lines
9.0 KiB
TypeScript
/**
|
|
* @file Chat.test.tsx
|
|
* @description Tests for Chat component error handling in imperative handle methods
|
|
*/
|
|
|
|
import { createRef } from "react";
|
|
import { render, fireEvent, waitFor } from "@testing-library/react";
|
|
import { describe, it, expect, beforeEach, vi, afterEach, type MockedFunction } from "vitest";
|
|
import { Chat, type ChatRef } from "./Chat";
|
|
import * as useChatModule from "@/hooks/useChat";
|
|
import * as useWebSocketModule from "@/hooks/useWebSocket";
|
|
import * as authModule from "@/lib/auth/auth-context";
|
|
import * as orchestratorModule from "@/hooks/useOrchestratorCommands";
|
|
|
|
// Mock scrollIntoView (not available in JSDOM)
|
|
Element.prototype.scrollIntoView = vi.fn();
|
|
|
|
// Mock dependencies
|
|
vi.mock("@/lib/auth/auth-context", () => ({
|
|
useAuth: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/hooks/useChat", () => ({
|
|
useChat: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/hooks/useWebSocket", () => ({
|
|
useWebSocket: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("./MessageList", () => ({
|
|
MessageList: (): React.ReactElement => <div data-testid="message-list" />,
|
|
}));
|
|
|
|
vi.mock("./ChatInput", () => ({
|
|
ChatInput: ({
|
|
onSend,
|
|
}: {
|
|
onSend: (content: string) => Promise<void>;
|
|
disabled: boolean;
|
|
inputRef: React.RefObject<HTMLTextAreaElement | null>;
|
|
}): React.ReactElement => (
|
|
<>
|
|
<button data-testid="chat-input" onClick={(): void => void onSend("test message")}>
|
|
Send
|
|
</button>
|
|
<button data-testid="chat-input-command" onClick={(): void => void onSend("/status")}>
|
|
Send Command
|
|
</button>
|
|
</>
|
|
),
|
|
DEFAULT_TEMPERATURE: 0.7,
|
|
DEFAULT_MAX_TOKENS: 4096,
|
|
DEFAULT_MODEL: "llama3.2",
|
|
AVAILABLE_MODELS: [
|
|
{ id: "llama3.2", label: "Llama 3.2" },
|
|
{ id: "claude-3.5-sonnet", label: "Claude 3.5 Sonnet" },
|
|
{ id: "gpt-4o", label: "GPT-4o" },
|
|
{ id: "deepseek-r1", label: "DeepSeek R1" },
|
|
],
|
|
}));
|
|
|
|
vi.mock("@/hooks/useOrchestratorCommands", () => ({
|
|
useOrchestratorCommands: vi.fn(),
|
|
}));
|
|
|
|
const mockUseAuth = authModule.useAuth as MockedFunction<typeof authModule.useAuth>;
|
|
const mockUseChat = useChatModule.useChat as MockedFunction<typeof useChatModule.useChat>;
|
|
const mockUseWebSocket = useWebSocketModule.useWebSocket as MockedFunction<
|
|
typeof useWebSocketModule.useWebSocket
|
|
>;
|
|
const mockUseOrchestratorCommands = orchestratorModule.useOrchestratorCommands as MockedFunction<
|
|
typeof orchestratorModule.useOrchestratorCommands
|
|
>;
|
|
|
|
function createMockUseChatReturn(
|
|
overrides: Partial<useChatModule.UseChatReturn> = {}
|
|
): useChatModule.UseChatReturn {
|
|
return {
|
|
messages: [
|
|
{
|
|
id: "welcome",
|
|
role: "assistant",
|
|
content: "Hello!",
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
],
|
|
isLoading: false,
|
|
isStreaming: false,
|
|
error: null,
|
|
conversationId: null,
|
|
conversationTitle: null,
|
|
sendMessage: vi.fn().mockResolvedValue(undefined),
|
|
abortStream: vi.fn(),
|
|
loadConversation: vi.fn().mockResolvedValue(undefined),
|
|
startNewConversation: vi.fn(),
|
|
setMessages: vi.fn(),
|
|
clearError: vi.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("Chat", () => {
|
|
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
|
|
mockUseAuth.mockReturnValue({
|
|
user: { id: "user-1", name: "Test User", email: "test@test.com" },
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
login: vi.fn(),
|
|
logout: vi.fn(),
|
|
} as unknown as ReturnType<typeof authModule.useAuth>);
|
|
|
|
mockUseWebSocket.mockReturnValue({
|
|
isConnected: true,
|
|
socket: null,
|
|
connectionError: null,
|
|
});
|
|
|
|
// Default: no commands intercepted
|
|
mockUseOrchestratorCommands.mockReturnValue({
|
|
isCommand: vi.fn().mockReturnValue(false),
|
|
executeCommand: vi.fn().mockResolvedValue(null),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
describe("loadConversation via ref", () => {
|
|
it("should delegate to useChat.loadConversation", async () => {
|
|
const mockLoadConversation = vi.fn().mockResolvedValue(undefined);
|
|
mockUseChat.mockReturnValue(
|
|
createMockUseChatReturn({ loadConversation: mockLoadConversation })
|
|
);
|
|
|
|
const ref = createRef<ChatRef>();
|
|
render(<Chat ref={ref} />);
|
|
|
|
await ref.current?.loadConversation("conv-123");
|
|
|
|
expect(mockLoadConversation).toHaveBeenCalledWith("conv-123");
|
|
});
|
|
|
|
it("should not re-throw when useChat.loadConversation handles errors internally", async () => {
|
|
// useChat.loadConversation handles errors internally (sets error state, logs, calls onError)
|
|
// and does NOT re-throw, so the imperative handle should resolve cleanly
|
|
const mockLoadConversation = vi.fn().mockResolvedValue(undefined);
|
|
mockUseChat.mockReturnValue(
|
|
createMockUseChatReturn({ loadConversation: mockLoadConversation })
|
|
);
|
|
|
|
const ref = createRef<ChatRef>();
|
|
render(<Chat ref={ref} />);
|
|
|
|
// Should resolve without throwing
|
|
await expect(ref.current?.loadConversation("conv-123")).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("sendMessage delegation", () => {
|
|
it("should delegate to useChat.sendMessage", async () => {
|
|
const mockSendMessage = vi.fn().mockResolvedValue(undefined);
|
|
mockUseChat.mockReturnValue(createMockUseChatReturn({ sendMessage: mockSendMessage }));
|
|
|
|
const ref = createRef<ChatRef>();
|
|
const { getByTestId } = render(<Chat ref={ref} />);
|
|
|
|
const sendButton = getByTestId("chat-input");
|
|
sendButton.click();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(mockSendMessage).toHaveBeenCalledWith("test message");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("orchestrator command routing", () => {
|
|
it("routes command messages through orchestrator instead of LLM", async () => {
|
|
const mockSendMessage = vi.fn().mockResolvedValue(undefined);
|
|
const mockSetMessages = vi.fn();
|
|
const mockExecuteCommand = vi.fn().mockResolvedValue({
|
|
id: "orch-123",
|
|
role: "assistant" as const,
|
|
content: "**Orchestrator Status**\n\n| Field | Value |\n|---|---|\n| Status | **Ready** |",
|
|
createdAt: new Date().toISOString(),
|
|
});
|
|
|
|
mockUseChat.mockReturnValue(
|
|
createMockUseChatReturn({
|
|
sendMessage: mockSendMessage,
|
|
setMessages: mockSetMessages,
|
|
})
|
|
);
|
|
|
|
mockUseOrchestratorCommands.mockReturnValue({
|
|
isCommand: (content: string) => content.trim().startsWith("/"),
|
|
executeCommand: mockExecuteCommand,
|
|
});
|
|
|
|
const { getByTestId } = render(<Chat />);
|
|
|
|
const commandButton = getByTestId("chat-input-command");
|
|
fireEvent.click(commandButton);
|
|
|
|
await waitFor(() => {
|
|
// executeCommand should have been called with the slash command
|
|
expect(mockExecuteCommand).toHaveBeenCalledWith("/status");
|
|
});
|
|
|
|
// sendMessage should NOT have been called
|
|
expect(mockSendMessage).not.toHaveBeenCalled();
|
|
|
|
// setMessages should have been called to add user and assistant messages
|
|
await waitFor(() => {
|
|
expect(mockSetMessages).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
it("does not call orchestrator for regular messages", async () => {
|
|
const mockSendMessage = vi.fn().mockResolvedValue(undefined);
|
|
const mockExecuteCommand = vi.fn().mockResolvedValue(null);
|
|
|
|
mockUseChat.mockReturnValue(createMockUseChatReturn({ sendMessage: mockSendMessage }));
|
|
|
|
mockUseOrchestratorCommands.mockReturnValue({
|
|
isCommand: vi.fn().mockReturnValue(false),
|
|
executeCommand: mockExecuteCommand,
|
|
});
|
|
|
|
const { getByTestId } = render(<Chat />);
|
|
|
|
fireEvent.click(getByTestId("chat-input"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockSendMessage).toHaveBeenCalledWith("test message");
|
|
});
|
|
|
|
expect(mockExecuteCommand).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("still adds user message to chat for commands", async () => {
|
|
const mockSetMessages = vi.fn();
|
|
const mockExecuteCommand = vi.fn().mockResolvedValue({
|
|
id: "orch-456",
|
|
role: "assistant" as const,
|
|
content: "Help content",
|
|
createdAt: new Date().toISOString(),
|
|
});
|
|
|
|
mockUseChat.mockReturnValue(createMockUseChatReturn({ setMessages: mockSetMessages }));
|
|
|
|
mockUseOrchestratorCommands.mockReturnValue({
|
|
isCommand: (content: string) => content.trim().startsWith("/"),
|
|
executeCommand: mockExecuteCommand,
|
|
});
|
|
|
|
const { getByTestId } = render(<Chat />);
|
|
|
|
fireEvent.click(getByTestId("chat-input-command"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockSetMessages).toHaveBeenCalled();
|
|
});
|
|
|
|
// First setMessages call should add the user message
|
|
const firstCall = mockSetMessages.mock.calls[0];
|
|
if (!firstCall) throw new Error("Expected setMessages to have been called");
|
|
const updater = firstCall[0] as (prev: useChatModule.Message[]) => useChatModule.Message[];
|
|
const result = updater([]);
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).toMatchObject({
|
|
role: "user",
|
|
content: "/status",
|
|
});
|
|
});
|
|
});
|
|
});
|