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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user