From 893a13908703e33d29eb127033e40e8521db0d81 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 6 Feb 2026 20:04:53 -0600 Subject: [PATCH] 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 --- apps/web/src/components/chat/Chat.test.tsx | 198 +++++++++++++++++++++ apps/web/src/components/chat/Chat.tsx | 15 +- apps/web/src/hooks/useChat.test.ts | 171 ++++++++++++++++++ apps/web/src/hooks/useChat.ts | 25 ++- apps/web/src/hooks/useChatOverlay.test.ts | 138 +++++++++++++- apps/web/src/hooks/useChatOverlay.ts | 55 +++++- apps/web/src/lib/utils/safe-json.test.ts | 7 +- apps/web/src/lib/utils/safe-json.ts | 6 +- 8 files changed, 598 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/chat/Chat.test.tsx diff --git a/apps/web/src/components/chat/Chat.test.tsx b/apps/web/src/components/chat/Chat.test.tsx new file mode 100644 index 0000000..43205ac --- /dev/null +++ b/apps/web/src/components/chat/Chat.test.tsx @@ -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 =>
, +})); + +vi.mock("./ChatInput", () => ({ + ChatInput: ({ + onSend, + }: { + onSend: (content: string) => Promise; + disabled: boolean; + inputRef: React.RefObject; + }): React.ReactElement => ( + + ), +})); + +const mockUseAuth = authModule.useAuth as MockedFunction; +const mockUseChat = useChatModule.useChat as MockedFunction; +const mockUseWebSocket = useWebSocketModule.useWebSocket as MockedFunction< + typeof useWebSocketModule.useWebSocket +>; + +function createMockUseChatReturn( + overrides: Partial = {} +): 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; + + 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); + + 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(); + render(); + + 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(); + render(); + + 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(); + render(); + + 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(); + render(); + + // 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(); + const { getByTestId } = render(); + + // 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); + }); + }); + }); +}); diff --git a/apps/web/src/components/chat/Chat.tsx b/apps/web/src/components/chat/Chat.tsx index 69f4550..c79dba7 100644 --- a/apps/web/src/components/chat/Chat.tsx +++ b/apps/web/src/components/chat/Chat.tsx @@ -96,8 +96,13 @@ export const Chat = forwardRef(function Chat( // Expose methods to parent via ref useImperativeHandle(ref, () => ({ - loadConversation: async (conversationId: string): Promise => { - await loadConversation(conversationId); + loadConversation: async (cId: string): Promise => { + 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(function Chat( const handleSendMessage = useCallback( async (content: string) => { - await sendMessage(content); + try { + await sendMessage(content); + } catch (err) { + console.error("Error sending message:", err); + } }, [sendMessage] ); diff --git a/apps/web/src/hooks/useChat.test.ts b/apps/web/src/hooks/useChat.test.ts index 012a050..7c98325 100644 --- a/apps/web/src/hooks/useChat.test.ts +++ b/apps/web/src/hooks/useChat.test.ts @@ -420,4 +420,175 @@ describe("useChat", () => { 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); + }); + }); }); diff --git a/apps/web/src/hooks/useChat.ts b/apps/web/src/hooks/useChat.ts index 5426d52..6a969f8 100644 --- a/apps/web/src/hooks/useChat.ts +++ b/apps/web/src/hooks/useChat.ts @@ -208,12 +208,33 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn { ? generateTitle(content) : (conversationTitle ?? "Chat Conversation"); - // Save conversation - await saveConversation(finalMessages, title); + // Save conversation (separate error handling from LLM errors) + 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) { const errorMsg = err instanceof Error ? err.message : "Failed to send message"; setError(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 const errorMessage: Message = { diff --git a/apps/web/src/hooks/useChatOverlay.test.ts b/apps/web/src/hooks/useChatOverlay.test.ts index dbed8e6..5b2d37f 100644 --- a/apps/web/src/hooks/useChatOverlay.test.ts +++ b/apps/web/src/hooks/useChatOverlay.test.ts @@ -260,7 +260,7 @@ describe("useChatOverlay", () => { 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()); act(() => { @@ -274,8 +274,24 @@ describe("useChatOverlay", () => { 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.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); }); }); + + 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); + }); + }); }); diff --git a/apps/web/src/hooks/useChatOverlay.ts b/apps/web/src/hooks/useChatOverlay.ts index 675f2d1..fefdf95 100644 --- a/apps/web/src/hooks/useChatOverlay.ts +++ b/apps/web/src/hooks/useChatOverlay.ts @@ -3,7 +3,7 @@ * @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"; interface ChatOverlayState { @@ -11,6 +11,10 @@ interface ChatOverlayState { isMinimized: boolean; } +export interface UseChatOverlayOptions { + onStorageError?: (message: string) => void; +} + interface UseChatOverlayResult extends ChatOverlayState { open: () => void; close: () => void; @@ -27,6 +31,23 @@ const DEFAULT_STATE: ChatOverlayState = { 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 */ @@ -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 +): void { if (typeof window === "undefined") { return; } @@ -58,20 +83,31 @@ function saveState(state: ChatOverlayState): void { try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (error) { + const errorName = error instanceof DOMException ? error.name : "UnknownError"; 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 - * 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(loadState); + const notifiedErrorsRef = useRef>(new Set()); + const onStorageErrorRef = useRef(onStorageError); + onStorageErrorRef.current = onStorageError; // Persist state changes to localStorage useEffect(() => { - saveState(state); + saveState(state, onStorageErrorRef.current, notifiedErrorsRef.current); }, [state]); const open = useCallback(() => { @@ -79,7 +115,7 @@ export function useChatOverlay(): UseChatOverlayResult { }, []); const close = useCallback(() => { - setState((prev) => ({ ...prev, isOpen: false })); + setState({ isOpen: false, isMinimized: false }); }, []); const minimize = useCallback(() => { @@ -91,7 +127,10 @@ export function useChatOverlay(): UseChatOverlayResult { }, []); 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(() => { diff --git a/apps/web/src/lib/utils/safe-json.test.ts b/apps/web/src/lib/utils/safe-json.test.ts index 5ae198a..6257661 100644 --- a/apps/web/src/lib/utils/safe-json.test.ts +++ b/apps/web/src/lib/utils/safe-json.test.ts @@ -170,7 +170,12 @@ describe("isMessageArray", () => { describe("isChatOverlayState", () => { it("should return true for a valid ChatOverlayState", () => { 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", () => { diff --git a/apps/web/src/lib/utils/safe-json.ts b/apps/web/src/lib/utils/safe-json.ts index fe5816f..9f27afd 100644 --- a/apps/web/src/lib/utils/safe-json.ts +++ b/apps/web/src/lib/utils/safe-json.ts @@ -76,12 +76,16 @@ export function isMessageArray(value: unknown): value is { /** * Type guard: validates ChatOverlayState shape * Expects { isOpen: boolean, isMinimized: boolean } + * Enforces invariant: cannot be minimized when closed */ export function isChatOverlayState( value: unknown ): value is { isOpen: boolean; isMinimized: boolean } { 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; } /**