Files
stack/apps/web/src/components/chat/Chat.test.tsx
Jason Woltje b110c469c4
All checks were successful
ci/woodpecker/push/web Pipeline was successful
feat(web): add orchestrator command system in chat interface (#521)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-26 03:39:00 +00:00

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",
});
});
});
});