feat(web): Integrate M4-LLM error handling improvements
Port high-value features from work/m4-llm branch into develop's security-hardened codebase: - Separate LLM vs persistence error handling in useChat (shows assistant response even when save fails) - Add structured error context logging with errorType, messagePreview, messageCount fields for debugging - Enforce state invariant in useChatOverlay: cannot be minimized when closed - Add onStorageError callback with user-friendly messages and per-error-type deduplication - Add error logging to Chat imperative handle methods - Create Chat.test.tsx with loadConversation failure mode tests Skipped from work/m4-llm (superseded by develop): - AbortSignal timeout (develop has centralized client timeout) - Custom toast system (duplicates @mosaic/ui) - ErrorBoundary (develop has its own) - WebSocket typed events (develop's ref-based pattern is superior) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
198
apps/web/src/components/chat/Chat.test.tsx
Normal file
198
apps/web/src/components/chat/Chat.test.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* @file Chat.test.tsx
|
||||
* @description Tests for Chat component error handling in imperative handle methods
|
||||
*/
|
||||
|
||||
import { createRef } from "react";
|
||||
import { render } 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";
|
||||
|
||||
// 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>
|
||||
),
|
||||
}));
|
||||
|
||||
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
|
||||
>;
|
||||
|
||||
function createMockUseChatReturn(
|
||||
overrides: Partial<useChatModule.UseChatReturn> = {}
|
||||
): useChatModule.UseChatReturn {
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
id: "welcome",
|
||||
role: "assistant",
|
||||
content: "Hello!",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
conversationId: null,
|
||||
conversationTitle: null,
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe("loadConversation via ref", () => {
|
||||
it("should successfully load a conversation", 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 log error context and re-throw on network failure", async () => {
|
||||
const networkError = new Error("Network request failed");
|
||||
const mockLoadConversation = vi.fn().mockRejectedValue(networkError);
|
||||
mockUseChat.mockReturnValue(
|
||||
createMockUseChatReturn({ loadConversation: mockLoadConversation })
|
||||
);
|
||||
|
||||
const ref = createRef<ChatRef>();
|
||||
render(<Chat ref={ref} />);
|
||||
|
||||
await expect(ref.current?.loadConversation("conv-123")).rejects.toThrow(
|
||||
"Network request failed"
|
||||
);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Failed to load conversation",
|
||||
expect.objectContaining({
|
||||
error: networkError,
|
||||
conversationId: "conv-123",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should log error context and re-throw on API error (500)", async () => {
|
||||
const apiError = new Error("Internal Server Error");
|
||||
const mockLoadConversation = vi.fn().mockRejectedValue(apiError);
|
||||
mockUseChat.mockReturnValue(
|
||||
createMockUseChatReturn({ loadConversation: mockLoadConversation })
|
||||
);
|
||||
|
||||
const ref = createRef<ChatRef>();
|
||||
render(<Chat ref={ref} />);
|
||||
|
||||
await expect(ref.current?.loadConversation("conv-456")).rejects.toThrow(
|
||||
"Internal Server Error"
|
||||
);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Failed to load conversation",
|
||||
expect.objectContaining({
|
||||
conversationId: "conv-456",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should re-throw error for caller to handle", async () => {
|
||||
const mockLoadConversation = vi.fn().mockRejectedValue(new Error("Auth failed"));
|
||||
mockUseChat.mockReturnValue(
|
||||
createMockUseChatReturn({ loadConversation: mockLoadConversation })
|
||||
);
|
||||
|
||||
const ref = createRef<ChatRef>();
|
||||
render(<Chat ref={ref} />);
|
||||
|
||||
// Verify the error propagates to the caller
|
||||
await expect(ref.current?.loadConversation("conv-789")).rejects.toThrow("Auth failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage error handling", () => {
|
||||
it("should log error when sendMessage fails", async () => {
|
||||
const sendError = new Error("Send failed");
|
||||
const mockSendMessage = vi.fn().mockRejectedValue(sendError);
|
||||
mockUseChat.mockReturnValue(createMockUseChatReturn({ sendMessage: mockSendMessage }));
|
||||
|
||||
const ref = createRef<ChatRef>();
|
||||
const { getByTestId } = render(<Chat ref={ref} />);
|
||||
|
||||
// Click the send button (which calls handleSendMessage)
|
||||
const sendButton = getByTestId("chat-input");
|
||||
sendButton.click();
|
||||
|
||||
// Wait for async handling
|
||||
await vi.waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Error sending message:", sendError);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -96,8 +96,13 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
|
||||
// Expose methods to parent via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
loadConversation: async (conversationId: string): Promise<void> => {
|
||||
await loadConversation(conversationId);
|
||||
loadConversation: async (cId: string): Promise<void> => {
|
||||
try {
|
||||
await loadConversation(cId);
|
||||
} catch (err) {
|
||||
console.error("Failed to load conversation", { error: err, conversationId: cId });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
startNewConversation: (projectId?: string | null): void => {
|
||||
startNewConversation(projectId);
|
||||
@@ -175,7 +180,11 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
await sendMessage(content);
|
||||
try {
|
||||
await sendMessage(content);
|
||||
} catch (err) {
|
||||
console.error("Error sending message:", err);
|
||||
}
|
||||
},
|
||||
[sendMessage]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user