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
|
// Expose methods to parent via ref
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
loadConversation: async (conversationId: string): Promise<void> => {
|
loadConversation: async (cId: string): Promise<void> => {
|
||||||
await loadConversation(conversationId);
|
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?: string | null): void => {
|
||||||
startNewConversation(projectId);
|
startNewConversation(projectId);
|
||||||
@@ -175,7 +180,11 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
|
|
||||||
const handleSendMessage = useCallback(
|
const handleSendMessage = useCallback(
|
||||||
async (content: string) => {
|
async (content: string) => {
|
||||||
await sendMessage(content);
|
try {
|
||||||
|
await sendMessage(content);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error sending message:", err);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[sendMessage]
|
[sendMessage]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -420,4 +420,175 @@ describe("useChat", () => {
|
|||||||
expect(result.current.error).toBeNull();
|
expect(result.current.error).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("error context logging", () => {
|
||||||
|
it("should log comprehensive error context when sendMessage fails", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
mockSendChatMessage.mockRejectedValueOnce(new Error("LLM timeout"));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChat({ model: "llama3.2" }));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendMessage("Hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
"Failed to send chat message",
|
||||||
|
expect.objectContaining({
|
||||||
|
errorType: "LLM_ERROR",
|
||||||
|
messageLength: 11,
|
||||||
|
messagePreview: "Hello world",
|
||||||
|
model: "llama3.2",
|
||||||
|
timestamp: expect.any(String) as string,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should truncate long message previews to 50 characters", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
mockSendChatMessage.mockRejectedValueOnce(new Error("Failed"));
|
||||||
|
|
||||||
|
const longMessage = "A".repeat(100);
|
||||||
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendMessage(longMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
"Failed to send chat message",
|
||||||
|
expect.objectContaining({
|
||||||
|
messagePreview: "A".repeat(50),
|
||||||
|
messageLength: 100,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include message count in error context", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
// First successful message
|
||||||
|
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("OK"));
|
||||||
|
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendMessage("First");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second message fails
|
||||||
|
mockSendChatMessage.mockRejectedValueOnce(new Error("Fail"));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendMessage("Second");
|
||||||
|
});
|
||||||
|
|
||||||
|
// messageCount should reflect messages including the new user message
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
"Failed to send chat message",
|
||||||
|
expect.objectContaining({
|
||||||
|
messageCount: expect.any(Number) as number,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LLM vs persistence error separation", () => {
|
||||||
|
it("should show LLM error and add error message to chat when API fails", async () => {
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
mockSendChatMessage.mockRejectedValueOnce(new Error("Model not available"));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendMessage("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.error).toBe("Model not available");
|
||||||
|
// Should have welcome + user + error message
|
||||||
|
expect(result.current.messages).toHaveLength(3);
|
||||||
|
expect(result.current.messages[2]?.content).toContain("Error: Model not available");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should keep assistant message visible when save fails", async () => {
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Great answer!"));
|
||||||
|
mockCreateConversation.mockRejectedValueOnce(new Error("Database connection lost"));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendMessage("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assistant message should still be visible
|
||||||
|
expect(result.current.messages).toHaveLength(3); // welcome + user + assistant
|
||||||
|
expect(result.current.messages[2]?.content).toBe("Great answer!");
|
||||||
|
|
||||||
|
// Error should indicate persistence failure
|
||||||
|
expect(result.current.error).toContain("Message sent but failed to save");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should log with PERSISTENCE_ERROR type when save fails", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Response"));
|
||||||
|
mockCreateConversation.mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendMessage("Test");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
"Failed to save conversation",
|
||||||
|
expect.objectContaining({
|
||||||
|
errorType: "PERSISTENCE_ERROR",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should NOT have logged as LLM_ERROR
|
||||||
|
const llmErrorCalls = consoleSpy.mock.calls.filter((call) => {
|
||||||
|
const ctx: unknown = call[1];
|
||||||
|
return (
|
||||||
|
typeof ctx === "object" &&
|
||||||
|
ctx !== null &&
|
||||||
|
"errorType" in ctx &&
|
||||||
|
(ctx as { errorType: string }).errorType === "LLM_ERROR"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(llmErrorCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use different user-facing messages for LLM vs save errors", async () => {
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
// Test LLM error message
|
||||||
|
mockSendChatMessage.mockRejectedValueOnce(new Error("Timeout"));
|
||||||
|
const { result: result1 } = renderHook(() => useChat());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result1.current.sendMessage("Test");
|
||||||
|
});
|
||||||
|
|
||||||
|
const llmError = result1.current.error;
|
||||||
|
|
||||||
|
// Test save error message
|
||||||
|
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("OK"));
|
||||||
|
mockCreateConversation.mockRejectedValueOnce(new Error("DB down"));
|
||||||
|
const { result: result2 } = renderHook(() => useChat());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result2.current.sendMessage("Test");
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveError = result2.current.error;
|
||||||
|
|
||||||
|
// They should be different
|
||||||
|
expect(llmError).toBe("Timeout");
|
||||||
|
expect(saveError).toContain("Message sent but failed to save");
|
||||||
|
expect(llmError).not.toEqual(saveError);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -208,12 +208,33 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
? generateTitle(content)
|
? generateTitle(content)
|
||||||
: (conversationTitle ?? "Chat Conversation");
|
: (conversationTitle ?? "Chat Conversation");
|
||||||
|
|
||||||
// Save conversation
|
// Save conversation (separate error handling from LLM errors)
|
||||||
await saveConversation(finalMessages, title);
|
try {
|
||||||
|
await saveConversation(finalMessages, title);
|
||||||
|
} catch (saveErr) {
|
||||||
|
const saveErrorMsg =
|
||||||
|
saveErr instanceof Error ? saveErr.message : "Unknown persistence error";
|
||||||
|
setError(`Message sent but failed to save: ${saveErrorMsg}`);
|
||||||
|
console.error("Failed to save conversation", {
|
||||||
|
error: saveErr,
|
||||||
|
errorType: "PERSISTENCE_ERROR",
|
||||||
|
conversationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMsg = err instanceof Error ? err.message : "Failed to send message";
|
const errorMsg = err instanceof Error ? err.message : "Failed to send message";
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
onError?.(err instanceof Error ? err : new Error(errorMsg));
|
onError?.(err instanceof Error ? err : new Error(errorMsg));
|
||||||
|
console.error("Failed to send chat message", {
|
||||||
|
error: err,
|
||||||
|
errorType: "LLM_ERROR",
|
||||||
|
conversationId,
|
||||||
|
messageLength: content.length,
|
||||||
|
messagePreview: content.substring(0, 50),
|
||||||
|
model,
|
||||||
|
messageCount: messagesRef.current.length,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
// Add error message to chat
|
// Add error message to chat
|
||||||
const errorMessage: Message = {
|
const errorMessage: Message = {
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ describe("useChatOverlay", () => {
|
|||||||
expect(result.current.isOpen).toBe(false);
|
expect(result.current.isOpen).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not change minimized state when toggling", () => {
|
it("should reset minimized state when toggling closed", () => {
|
||||||
const { result } = renderHook(() => useChatOverlay());
|
const { result } = renderHook(() => useChatOverlay());
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -274,8 +274,24 @@ describe("useChatOverlay", () => {
|
|||||||
result.current.toggle();
|
result.current.toggle();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should close but keep minimized state for next open
|
// Should close and reset minimized (invariant: cannot be minimized when closed)
|
||||||
expect(result.current.isOpen).toBe(false);
|
expect(result.current.isOpen).toBe(false);
|
||||||
|
expect(result.current.isMinimized).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve minimized state when toggling open", () => {
|
||||||
|
const { result } = renderHook(() => useChatOverlay());
|
||||||
|
|
||||||
|
// Start closed
|
||||||
|
expect(result.current.isOpen).toBe(false);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.toggle();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should open and not be minimized
|
||||||
|
expect(result.current.isOpen).toBe(true);
|
||||||
|
expect(result.current.isMinimized).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -305,4 +321,122 @@ describe("useChatOverlay", () => {
|
|||||||
expect(result.current.isMinimized).toBe(false);
|
expect(result.current.isMinimized).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("state invariant enforcement", () => {
|
||||||
|
it("should reject invalid localStorage state: closed AND minimized", () => {
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
|
// This violates the invariant: cannot be minimized when closed
|
||||||
|
localStorageMock.setItem(
|
||||||
|
"chatOverlayState",
|
||||||
|
JSON.stringify({ isOpen: false, isMinimized: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChatOverlay());
|
||||||
|
|
||||||
|
// Should fall back to defaults since invariant is violated
|
||||||
|
expect(result.current.isOpen).toBe(false);
|
||||||
|
expect(result.current.isMinimized).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept valid state: open and minimized", () => {
|
||||||
|
localStorageMock.setItem(
|
||||||
|
"chatOverlayState",
|
||||||
|
JSON.stringify({ isOpen: true, isMinimized: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChatOverlay());
|
||||||
|
|
||||||
|
expect(result.current.isOpen).toBe(true);
|
||||||
|
expect(result.current.isMinimized).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reset isMinimized when closing via close()", () => {
|
||||||
|
const { result } = renderHook(() => useChatOverlay());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.open();
|
||||||
|
result.current.minimize();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isMinimized).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isOpen).toBe(false);
|
||||||
|
expect(result.current.isMinimized).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storage error handling", () => {
|
||||||
|
it("should call onStorageError when localStorage save fails", () => {
|
||||||
|
const onStorageError = vi.fn();
|
||||||
|
const quotaError = new DOMException("Storage full", "QuotaExceededError");
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChatOverlay({ onStorageError }));
|
||||||
|
|
||||||
|
// Make setItem throw
|
||||||
|
vi.spyOn(window.localStorage, "setItem").mockImplementation(() => {
|
||||||
|
throw quotaError;
|
||||||
|
});
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onStorageError).toHaveBeenCalledWith(
|
||||||
|
"Storage is full. Chat state may not persist across page refreshes."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show appropriate message for SecurityError", () => {
|
||||||
|
const onStorageError = vi.fn();
|
||||||
|
const securityError = new DOMException("Blocked", "SecurityError");
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChatOverlay({ onStorageError }));
|
||||||
|
|
||||||
|
vi.spyOn(window.localStorage, "setItem").mockImplementation(() => {
|
||||||
|
throw securityError;
|
||||||
|
});
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onStorageError).toHaveBeenCalledWith(
|
||||||
|
"Storage is unavailable in this browser mode. Chat state will not persist."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should only notify once per error type", () => {
|
||||||
|
const onStorageError = vi.fn();
|
||||||
|
const quotaError = new DOMException("Storage full", "QuotaExceededError");
|
||||||
|
|
||||||
|
// Set up spy BEFORE rendering so all saves (including initial) throw
|
||||||
|
vi.spyOn(window.localStorage, "setItem").mockImplementation(() => {
|
||||||
|
throw quotaError;
|
||||||
|
});
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChatOverlay({ onStorageError }));
|
||||||
|
|
||||||
|
// Initial render triggers a save which throws → first notification
|
||||||
|
// Multiple state changes that trigger more saves
|
||||||
|
act(() => {
|
||||||
|
result.current.open();
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
result.current.minimize();
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
result.current.expand();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should only have been called once for QuotaExceededError despite multiple failures
|
||||||
|
expect(onStorageError).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* @description Hook for managing the global chat overlay state
|
* @description Hook for managing the global chat overlay state
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { safeJsonParse, isChatOverlayState } from "@/lib/utils/safe-json";
|
import { safeJsonParse, isChatOverlayState } from "@/lib/utils/safe-json";
|
||||||
|
|
||||||
interface ChatOverlayState {
|
interface ChatOverlayState {
|
||||||
@@ -11,6 +11,10 @@ interface ChatOverlayState {
|
|||||||
isMinimized: boolean;
|
isMinimized: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UseChatOverlayOptions {
|
||||||
|
onStorageError?: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface UseChatOverlayResult extends ChatOverlayState {
|
interface UseChatOverlayResult extends ChatOverlayState {
|
||||||
open: () => void;
|
open: () => void;
|
||||||
close: () => void;
|
close: () => void;
|
||||||
@@ -27,6 +31,23 @@ const DEFAULT_STATE: ChatOverlayState = {
|
|||||||
isMinimized: false,
|
isMinimized: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a user-friendly error message for localStorage failures
|
||||||
|
*/
|
||||||
|
function getStorageErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof DOMException) {
|
||||||
|
switch (error.name) {
|
||||||
|
case "QuotaExceededError":
|
||||||
|
return "Storage is full. Chat state may not persist across page refreshes.";
|
||||||
|
case "SecurityError":
|
||||||
|
return "Storage is unavailable in this browser mode. Chat state will not persist.";
|
||||||
|
case "InvalidStateError":
|
||||||
|
return "Storage is disabled. Chat state will not persist across page refreshes.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Unable to save chat state. It may not persist across page refreshes.";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load state from localStorage with runtime type validation
|
* Load state from localStorage with runtime type validation
|
||||||
*/
|
*/
|
||||||
@@ -48,9 +69,13 @@ function loadState(): ChatOverlayState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save state to localStorage
|
* Save state to localStorage, notifying on error (once per error type)
|
||||||
*/
|
*/
|
||||||
function saveState(state: ChatOverlayState): void {
|
function saveState(
|
||||||
|
state: ChatOverlayState,
|
||||||
|
onStorageError: ((message: string) => void) | undefined,
|
||||||
|
notifiedErrors: Set<string>
|
||||||
|
): void {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -58,20 +83,31 @@ function saveState(state: ChatOverlayState): void {
|
|||||||
try {
|
try {
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const errorName = error instanceof DOMException ? error.name : "UnknownError";
|
||||||
console.warn("Failed to save chat overlay state to localStorage:", error);
|
console.warn("Failed to save chat overlay state to localStorage:", error);
|
||||||
|
|
||||||
|
if (onStorageError && !notifiedErrors.has(errorName)) {
|
||||||
|
notifiedErrors.add(errorName);
|
||||||
|
onStorageError(getStorageErrorMessage(error));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for managing chat overlay state
|
* Custom hook for managing chat overlay state
|
||||||
* Persists state to localStorage for consistency across page refreshes
|
* Persists state to localStorage for consistency across page refreshes.
|
||||||
|
* Enforces invariant: cannot be minimized when closed.
|
||||||
*/
|
*/
|
||||||
export function useChatOverlay(): UseChatOverlayResult {
|
export function useChatOverlay(options: UseChatOverlayOptions = {}): UseChatOverlayResult {
|
||||||
|
const { onStorageError } = options;
|
||||||
const [state, setState] = useState<ChatOverlayState>(loadState);
|
const [state, setState] = useState<ChatOverlayState>(loadState);
|
||||||
|
const notifiedErrorsRef = useRef<Set<string>>(new Set());
|
||||||
|
const onStorageErrorRef = useRef(onStorageError);
|
||||||
|
onStorageErrorRef.current = onStorageError;
|
||||||
|
|
||||||
// Persist state changes to localStorage
|
// Persist state changes to localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
saveState(state);
|
saveState(state, onStorageErrorRef.current, notifiedErrorsRef.current);
|
||||||
}, [state]);
|
}, [state]);
|
||||||
|
|
||||||
const open = useCallback(() => {
|
const open = useCallback(() => {
|
||||||
@@ -79,7 +115,7 @@ export function useChatOverlay(): UseChatOverlayResult {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const close = useCallback(() => {
|
const close = useCallback(() => {
|
||||||
setState((prev) => ({ ...prev, isOpen: false }));
|
setState({ isOpen: false, isMinimized: false });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const minimize = useCallback(() => {
|
const minimize = useCallback(() => {
|
||||||
@@ -91,7 +127,10 @@ export function useChatOverlay(): UseChatOverlayResult {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
setState((prev) => ({ ...prev, isOpen: !prev.isOpen }));
|
setState((prev) => {
|
||||||
|
const newIsOpen = !prev.isOpen;
|
||||||
|
return { isOpen: newIsOpen, isMinimized: newIsOpen ? prev.isMinimized : false };
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleMinimize = useCallback(() => {
|
const toggleMinimize = useCallback(() => {
|
||||||
|
|||||||
@@ -170,7 +170,12 @@ describe("isMessageArray", () => {
|
|||||||
describe("isChatOverlayState", () => {
|
describe("isChatOverlayState", () => {
|
||||||
it("should return true for a valid ChatOverlayState", () => {
|
it("should return true for a valid ChatOverlayState", () => {
|
||||||
expect(isChatOverlayState({ isOpen: true, isMinimized: false })).toBe(true);
|
expect(isChatOverlayState({ isOpen: true, isMinimized: false })).toBe(true);
|
||||||
expect(isChatOverlayState({ isOpen: false, isMinimized: true })).toBe(true);
|
expect(isChatOverlayState({ isOpen: true, isMinimized: true })).toBe(true);
|
||||||
|
expect(isChatOverlayState({ isOpen: false, isMinimized: false })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid state: closed AND minimized (invariant violation)", () => {
|
||||||
|
expect(isChatOverlayState({ isOpen: false, isMinimized: true })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return false for non-object values", () => {
|
it("should return false for non-object values", () => {
|
||||||
|
|||||||
@@ -76,12 +76,16 @@ export function isMessageArray(value: unknown): value is {
|
|||||||
/**
|
/**
|
||||||
* Type guard: validates ChatOverlayState shape
|
* Type guard: validates ChatOverlayState shape
|
||||||
* Expects { isOpen: boolean, isMinimized: boolean }
|
* Expects { isOpen: boolean, isMinimized: boolean }
|
||||||
|
* Enforces invariant: cannot be minimized when closed
|
||||||
*/
|
*/
|
||||||
export function isChatOverlayState(
|
export function isChatOverlayState(
|
||||||
value: unknown
|
value: unknown
|
||||||
): value is { isOpen: boolean; isMinimized: boolean } {
|
): value is { isOpen: boolean; isMinimized: boolean } {
|
||||||
if (!isRecord(value)) return false;
|
if (!isRecord(value)) return false;
|
||||||
return typeof value.isOpen === "boolean" && typeof value.isMinimized === "boolean";
|
if (typeof value.isOpen !== "boolean" || typeof value.isMinimized !== "boolean") return false;
|
||||||
|
// Invariant: cannot be minimized when closed
|
||||||
|
if (!value.isOpen && value.isMinimized) return false;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user