feat(web): Integrate M4-LLM error handling improvements
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline failed

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:
Jason Woltje
2026-02-06 20:04:53 -06:00
parent ac796072d8
commit 893a139087
8 changed files with 598 additions and 17 deletions

View 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);
});
});
});
});

View File

@@ -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]
);