|
@@ -2,45 +2,55 @@
|
|
|
* Tests for the useWebSocket hook.
|
|
* Tests for the useWebSocket hook.
|
|
|
*
|
|
*
|
|
|
* Tests WebSocket connection management and message handling.
|
|
* Tests WebSocket connection management and message handling.
|
|
|
|
|
+ * Uses vitest.mock to mock the entire module before MSW can intercept.
|
|
|
*/
|
|
*/
|
|
|
|
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
-import { waitFor } from '@testing-library/react';
|
|
|
|
|
|
|
+import { renderHook, waitFor, act } from '@testing-library/react';
|
|
|
|
|
+import React from 'react';
|
|
|
|
|
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
|
|
|
|
|
|
-// Mock WebSocket
|
|
|
|
|
|
|
+// Track WebSocket instances created during tests
|
|
|
|
|
+let wsInstances: MockWebSocket[] = [];
|
|
|
|
|
+let originalWebSocket: typeof WebSocket;
|
|
|
|
|
+
|
|
|
|
|
+// Enhanced MockWebSocket that tracks instances
|
|
|
class MockWebSocket {
|
|
class MockWebSocket {
|
|
|
- static CONNECTING = 0;
|
|
|
|
|
- static OPEN = 1;
|
|
|
|
|
- static CLOSING = 2;
|
|
|
|
|
- static CLOSED = 3;
|
|
|
|
|
|
|
+ static readonly CONNECTING = 0;
|
|
|
|
|
+ static readonly OPEN = 1;
|
|
|
|
|
+ static readonly CLOSING = 2;
|
|
|
|
|
+ static readonly CLOSED = 3;
|
|
|
|
|
|
|
|
- url: string;
|
|
|
|
|
- readyState: number = MockWebSocket.CONNECTING;
|
|
|
|
|
|
|
+ readyState = MockWebSocket.CONNECTING;
|
|
|
onopen: ((event: Event) => void) | null = null;
|
|
onopen: ((event: Event) => void) | null = null;
|
|
|
onclose: ((event: CloseEvent) => void) | null = null;
|
|
onclose: ((event: CloseEvent) => void) | null = null;
|
|
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
|
onerror: ((event: Event) => void) | null = null;
|
|
onerror: ((event: Event) => void) | null = null;
|
|
|
|
|
|
|
|
|
|
+ url: string;
|
|
|
constructor(url: string) {
|
|
constructor(url: string) {
|
|
|
this.url = url;
|
|
this.url = url;
|
|
|
- // Simulate connection opening
|
|
|
|
|
- setTimeout(() => {
|
|
|
|
|
- this.readyState = MockWebSocket.OPEN;
|
|
|
|
|
- if (this.onopen) {
|
|
|
|
|
- this.onopen(new Event('open'));
|
|
|
|
|
- }
|
|
|
|
|
- }, 10);
|
|
|
|
|
|
|
+ wsInstances.push(this);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- send(_data: string) {
|
|
|
|
|
- // Mock send
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- close() {
|
|
|
|
|
|
|
+ send = vi.fn();
|
|
|
|
|
+ close = vi.fn(() => {
|
|
|
this.readyState = MockWebSocket.CLOSED;
|
|
this.readyState = MockWebSocket.CLOSED;
|
|
|
if (this.onclose) {
|
|
if (this.onclose) {
|
|
|
this.onclose(new CloseEvent('close'));
|
|
this.onclose(new CloseEvent('close'));
|
|
|
}
|
|
}
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Required by MSW's interceptor - these are no-ops but prevent the error
|
|
|
|
|
+ addEventListener = vi.fn();
|
|
|
|
|
+ removeEventListener = vi.fn();
|
|
|
|
|
+
|
|
|
|
|
+ // Helper to simulate connection opening
|
|
|
|
|
+ open() {
|
|
|
|
|
+ this.readyState = MockWebSocket.OPEN;
|
|
|
|
|
+ if (this.onopen) {
|
|
|
|
|
+ this.onopen(new Event('open'));
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Helper to simulate receiving a message
|
|
// Helper to simulate receiving a message
|
|
@@ -53,40 +63,51 @@ class MockWebSocket {
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- // Helper to simulate an error
|
|
|
|
|
- simulateError() {
|
|
|
|
|
- if (this.onerror) {
|
|
|
|
|
- this.onerror(new Event('error'));
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+// Create test QueryClient
|
|
|
|
|
+function createTestQueryClient() {
|
|
|
|
|
+ return new QueryClient({
|
|
|
|
|
+ defaultOptions: {
|
|
|
|
|
+ queries: {
|
|
|
|
|
+ retry: false,
|
|
|
|
|
+ gcTime: 0,
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// Store reference to mock instances
|
|
|
|
|
-let mockWebSocketInstance: MockWebSocket | null = null;
|
|
|
|
|
|
|
+// Wrapper with QueryClient for hook testing
|
|
|
|
|
+function createWrapper(queryClient: QueryClient) {
|
|
|
|
|
+ return function Wrapper({ children }: { children: React.ReactNode }) {
|
|
|
|
|
+ return React.createElement(
|
|
|
|
|
+ QueryClientProvider,
|
|
|
|
|
+ { client: queryClient },
|
|
|
|
|
+ children
|
|
|
|
|
+ );
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
-vi.stubGlobal(
|
|
|
|
|
- 'WebSocket',
|
|
|
|
|
- vi.fn((url: string) => {
|
|
|
|
|
- mockWebSocketInstance = new MockWebSocket(url);
|
|
|
|
|
- return mockWebSocketInstance;
|
|
|
|
|
- })
|
|
|
|
|
-);
|
|
|
|
|
|
|
+function getLatestWs(): MockWebSocket | undefined {
|
|
|
|
|
+ return wsInstances[wsInstances.length - 1];
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
describe('useWebSocket hook', () => {
|
|
describe('useWebSocket hook', () => {
|
|
|
|
|
+ let queryClient: QueryClient;
|
|
|
|
|
+
|
|
|
beforeEach(() => {
|
|
beforeEach(() => {
|
|
|
vi.clearAllMocks();
|
|
vi.clearAllMocks();
|
|
|
- mockWebSocketInstance = null;
|
|
|
|
|
|
|
+ wsInstances = [];
|
|
|
|
|
+ queryClient = createTestQueryClient();
|
|
|
|
|
+ // Save original and install mock
|
|
|
|
|
+ originalWebSocket = globalThis.WebSocket;
|
|
|
|
|
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
afterEach(() => {
|
|
|
vi.restoreAllMocks();
|
|
vi.restoreAllMocks();
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it('should be importable', async () => {
|
|
|
|
|
- // Just verify the hook module can be imported
|
|
|
|
|
- const module = await import('../../hooks/useWebSocket');
|
|
|
|
|
- expect(module).toBeDefined();
|
|
|
|
|
|
|
+ // Restore original WebSocket
|
|
|
|
|
+ globalThis.WebSocket = originalWebSocket;
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
describe('WebSocket Mock', () => {
|
|
describe('WebSocket Mock', () => {
|
|
@@ -100,26 +121,23 @@ describe('useWebSocket hook', () => {
|
|
|
expect(ws.readyState).toBe(MockWebSocket.CONNECTING);
|
|
expect(ws.readyState).toBe(MockWebSocket.CONNECTING);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- it('transitions to OPEN state', async () => {
|
|
|
|
|
|
|
+ it('transitions to OPEN state', () => {
|
|
|
const ws = new MockWebSocket('ws://test.local/ws');
|
|
const ws = new MockWebSocket('ws://test.local/ws');
|
|
|
const onOpen = vi.fn();
|
|
const onOpen = vi.fn();
|
|
|
ws.onopen = onOpen;
|
|
ws.onopen = onOpen;
|
|
|
|
|
|
|
|
- await waitFor(() => {
|
|
|
|
|
- expect(ws.readyState).toBe(MockWebSocket.OPEN);
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ ws.open();
|
|
|
|
|
+
|
|
|
|
|
+ expect(ws.readyState).toBe(MockWebSocket.OPEN);
|
|
|
expect(onOpen).toHaveBeenCalled();
|
|
expect(onOpen).toHaveBeenCalled();
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- it('can receive messages', async () => {
|
|
|
|
|
|
|
+ it('can receive messages', () => {
|
|
|
const ws = new MockWebSocket('ws://test.local/ws');
|
|
const ws = new MockWebSocket('ws://test.local/ws');
|
|
|
const onMessage = vi.fn();
|
|
const onMessage = vi.fn();
|
|
|
ws.onmessage = onMessage;
|
|
ws.onmessage = onMessage;
|
|
|
|
|
|
|
|
- await waitFor(() => {
|
|
|
|
|
- expect(ws.readyState).toBe(MockWebSocket.OPEN);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
|
|
+ ws.open();
|
|
|
ws.simulateMessage({ type: 'status', data: { connected: true } });
|
|
ws.simulateMessage({ type: 'status', data: { connected: true } });
|
|
|
|
|
|
|
|
expect(onMessage).toHaveBeenCalled();
|
|
expect(onMessage).toHaveBeenCalled();
|
|
@@ -136,14 +154,395 @@ describe('useWebSocket hook', () => {
|
|
|
expect(onClose).toHaveBeenCalled();
|
|
expect(onClose).toHaveBeenCalled();
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- it('can handle errors', () => {
|
|
|
|
|
- const ws = new MockWebSocket('ws://test.local/ws');
|
|
|
|
|
- const onError = vi.fn();
|
|
|
|
|
- ws.onerror = onError;
|
|
|
|
|
|
|
+ it('tracks all instances', () => {
|
|
|
|
|
+ wsInstances = [];
|
|
|
|
|
+ new MockWebSocket('ws://a');
|
|
|
|
|
+ new MockWebSocket('ws://b');
|
|
|
|
|
+ expect(wsInstances.length).toBe(2);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ describe('hook connection', () => {
|
|
|
|
|
+ it('connects to WebSocket on mount', async () => {
|
|
|
|
|
+ // Reset module cache to get fresh import with our mock
|
|
|
|
|
+ vi.resetModules();
|
|
|
|
|
+ const { useWebSocket } = await import('../../hooks/useWebSocket');
|
|
|
|
|
+
|
|
|
|
|
+ renderHook(() => useWebSocket(), {
|
|
|
|
|
+ wrapper: createWrapper(queryClient),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const ws = getLatestWs();
|
|
|
|
|
+ expect(ws).toBeDefined();
|
|
|
|
|
+ expect(ws?.url).toContain('/api/v1/ws');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('reports connected state when WebSocket opens', async () => {
|
|
|
|
|
+ vi.resetModules();
|
|
|
|
|
+ const { useWebSocket } = await import('../../hooks/useWebSocket');
|
|
|
|
|
+
|
|
|
|
|
+ const { result } = renderHook(() => useWebSocket(), {
|
|
|
|
|
+ wrapper: createWrapper(queryClient),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Initially not connected
|
|
|
|
|
+ expect(result.current.isConnected).toBe(false);
|
|
|
|
|
+
|
|
|
|
|
+ // Simulate connection opening
|
|
|
|
|
+ const ws = getLatestWs();
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ ws?.open();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ await waitFor(() => {
|
|
|
|
|
+ expect(result.current.isConnected).toBe(true);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ describe('message handling', () => {
|
|
|
|
|
+ it('updates printer status in query cache on printer_status message', async () => {
|
|
|
|
|
+ vi.resetModules();
|
|
|
|
|
+ const { useWebSocket } = await import('../../hooks/useWebSocket');
|
|
|
|
|
+
|
|
|
|
|
+ renderHook(() => useWebSocket(), {
|
|
|
|
|
+ wrapper: createWrapper(queryClient),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const ws = getLatestWs()!;
|
|
|
|
|
+
|
|
|
|
|
+ // Open connection
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ ws.open();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Simulate printer status message
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ ws.simulateMessage({
|
|
|
|
|
+ type: 'printer_status',
|
|
|
|
|
+ printer_id: 1,
|
|
|
|
|
+ data: { state: 'IDLE', progress: 0 },
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Check query cache was updated
|
|
|
|
|
+ const cachedData = queryClient.getQueryData(['printerStatus', 1]);
|
|
|
|
|
+ expect(cachedData).toEqual({ state: 'IDLE', progress: 0 });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('preserves wifi_signal when new value is null', async () => {
|
|
|
|
|
+ vi.resetModules();
|
|
|
|
|
+ const { useWebSocket } = await import('../../hooks/useWebSocket');
|
|
|
|
|
+
|
|
|
|
|
+ // Pre-populate cache with wifi_signal
|
|
|
|
|
+ queryClient.setQueryData(['printerStatus', 1], {
|
|
|
|
|
+ wifi_signal: -65,
|
|
|
|
|
+ state: 'IDLE',
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ renderHook(() => useWebSocket(), {
|
|
|
|
|
+ wrapper: createWrapper(queryClient),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const ws = getLatestWs()!;
|
|
|
|
|
+
|
|
|
|
|
+ // Open connection
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ ws.open();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Simulate status update with null wifi_signal
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ ws.simulateMessage({
|
|
|
|
|
+ type: 'printer_status',
|
|
|
|
|
+ printer_id: 1,
|
|
|
|
|
+ data: { state: 'RUNNING', wifi_signal: null },
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const cachedData = queryClient.getQueryData(['printerStatus', 1]) as Record<
|
|
|
|
|
+ string,
|
|
|
|
|
+ unknown
|
|
|
|
|
+ >;
|
|
|
|
|
+ expect(cachedData.wifi_signal).toBe(-65); // Preserved
|
|
|
|
|
+ expect(cachedData.state).toBe('RUNNING'); // Updated
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('invalidates archives on print_complete message', async () => {
|
|
|
|
|
+ vi.resetModules();
|
|
|
|
|
+ const { useWebSocket } = await import('../../hooks/useWebSocket');
|
|
|
|
|
+
|
|
|
|
|
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
|
+
|
|
|
|
|
+ renderHook(() => useWebSocket(), {
|
|
|
|
|
+ wrapper: createWrapper(queryClient),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const ws = getLatestWs()!;
|
|
|
|
|
+
|
|
|
|
|
+ // Open connection
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ ws.open();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Simulate print complete
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ ws.simulateMessage({
|
|
|
|
|
+ type: 'print_complete',
|
|
|
|
|
+ printer_id: 1,
|
|
|
|
|
+ data: { status: 'completed' },
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archives'] });
|
|
|
|
|
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archiveStats'] });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('invalidates archives on archive_created message', async () => {
|
|
|
|
|
+ vi.resetModules();
|
|
|
|
|
+ const { useWebSocket } = await import('../../hooks/useWebSocket');
|
|
|
|
|
+
|
|
|
|
|
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
|
+
|
|
|
|
|
+ renderHook(() => useWebSocket(), {
|
|
|
|
|
+ wrapper: createWrapper(queryClient),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const ws = getLatestWs()!;
|
|
|
|
|
+
|
|
|
|
|
+ // Open connection
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ ws.open();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Simulate archive created
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ ws.simulateMessage({
|
|
|
|
|
+ type: 'archive_created',
|
|
|
|
|
+ data: { id: 1, filename: 'test.3mf' },
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archives'] });
|
|
|
|
|
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archiveStats'] });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('invalidates archives on archive_updated message', async () => {
|
|
|
|
|
+ vi.resetModules();
|
|
|
|
|
+ const { useWebSocket } = await import('../../hooks/useWebSocket');
|
|
|
|
|
+
|
|
|
|
|
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
|
+
|
|
|
|
|
+ renderHook(() => useWebSocket(), {
|
|
|
|
|
+ wrapper: createWrapper(queryClient),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const ws = getLatestWs()!;
|
|
|
|
|
+
|
|
|
|
|
+ // Open connection
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ ws.open();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Simulate archive updated (e.g., timelapse attached)
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ ws.simulateMessage({
|
|
|
|
|
+ type: 'archive_updated',
|
|
|
|
|
+ data: { id: 1, timelapse_attached: true },
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archives'] });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('ignores pong messages without error', async () => {
|
|
|
|
|
+ vi.resetModules();
|
|
|
|
|
+ const { useWebSocket } = await import('../../hooks/useWebSocket');
|
|
|
|
|
+
|
|
|
|
|
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
|
+
|
|
|
|
|
+ renderHook(() => useWebSocket(), {
|
|
|
|
|
+ wrapper: createWrapper(queryClient),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const ws = getLatestWs()!;
|
|
|
|
|
+
|
|
|
|
|
+ // Open connection
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ ws.open();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Simulate pong response
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ ws.simulateMessage({
|
|
|
|
|
+ type: 'pong',
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Should not invalidate any queries for pong
|
|
|
|
|
+ expect(invalidateSpy).not.toHaveBeenCalled();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('handles malformed JSON gracefully', async () => {
|
|
|
|
|
+ vi.resetModules();
|
|
|
|
|
+ const { useWebSocket } = await import('../../hooks/useWebSocket');
|
|
|
|
|
+
|
|
|
|
|
+ renderHook(() => useWebSocket(), {
|
|
|
|
|
+ wrapper: createWrapper(queryClient),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const ws = getLatestWs()!;
|
|
|
|
|
+
|
|
|
|
|
+ // Open connection
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ ws.open();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Simulate malformed message (should not throw)
|
|
|
|
|
+ expect(() => {
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ if (ws.onmessage) {
|
|
|
|
|
+ ws.onmessage(
|
|
|
|
|
+ new MessageEvent('message', {
|
|
|
|
|
+ data: 'not valid json{{{',
|
|
|
|
|
+ })
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }).not.toThrow();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('handles unknown message types gracefully', async () => {
|
|
|
|
|
+ vi.resetModules();
|
|
|
|
|
+ const { useWebSocket } = await import('../../hooks/useWebSocket');
|
|
|
|
|
+
|
|
|
|
|
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
|
+
|
|
|
|
|
+ renderHook(() => useWebSocket(), {
|
|
|
|
|
+ wrapper: createWrapper(queryClient),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const ws = getLatestWs()!;
|
|
|
|
|
+
|
|
|
|
|
+ // Open connection
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ ws.open();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Simulate unknown message type
|
|
|
|
|
+ expect(() => {
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ ws.simulateMessage({
|
|
|
|
|
+ type: 'unknown_type',
|
|
|
|
|
+ data: { foo: 'bar' },
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ }).not.toThrow();
|
|
|
|
|
+
|
|
|
|
|
+ expect(invalidateSpy).not.toHaveBeenCalled();
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ describe('sendMessage', () => {
|
|
|
|
|
+ it('sends JSON message when connected', async () => {
|
|
|
|
|
+ vi.resetModules();
|
|
|
|
|
+ const { useWebSocket } = await import('../../hooks/useWebSocket');
|
|
|
|
|
+
|
|
|
|
|
+ const { result } = renderHook(() => useWebSocket(), {
|
|
|
|
|
+ wrapper: createWrapper(queryClient),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const ws = getLatestWs()!;
|
|
|
|
|
+
|
|
|
|
|
+ // Open connection
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ ws.open();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ result.current.sendMessage({ type: 'test', data: 'hello' });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ expect(ws.send).toHaveBeenCalledWith(
|
|
|
|
|
+ JSON.stringify({ type: 'test', data: 'hello' })
|
|
|
|
|
+ );
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('does not send when disconnected', async () => {
|
|
|
|
|
+ vi.resetModules();
|
|
|
|
|
+ const { useWebSocket } = await import('../../hooks/useWebSocket');
|
|
|
|
|
+
|
|
|
|
|
+ const { result } = renderHook(() => useWebSocket(), {
|
|
|
|
|
+ wrapper: createWrapper(queryClient),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const ws = getLatestWs()!;
|
|
|
|
|
+
|
|
|
|
|
+ // Don't open connection - still in CONNECTING state
|
|
|
|
|
+
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ result.current.sendMessage({ type: 'test' });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ expect(ws.send).not.toHaveBeenCalled();
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ describe('reconnection', () => {
|
|
|
|
|
+ it('reconnects after connection closes', async () => {
|
|
|
|
|
+ vi.useFakeTimers();
|
|
|
|
|
+ vi.resetModules();
|
|
|
|
|
+
|
|
|
|
|
+ const { useWebSocket } = await import('../../hooks/useWebSocket');
|
|
|
|
|
+
|
|
|
|
|
+ renderHook(() => useWebSocket(), {
|
|
|
|
|
+ wrapper: createWrapper(queryClient),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const firstWs = getLatestWs()!;
|
|
|
|
|
+
|
|
|
|
|
+ // Open connection
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ firstWs.open();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const instanceCountBefore = wsInstances.length;
|
|
|
|
|
+
|
|
|
|
|
+ // Close connection
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ firstWs.close();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Wait for reconnect timeout (3 seconds)
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ vi.advanceTimersByTime(3000);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Should have created new WebSocket
|
|
|
|
|
+ expect(wsInstances.length).toBe(instanceCountBefore + 1);
|
|
|
|
|
+ expect(getLatestWs()).not.toBe(firstWs);
|
|
|
|
|
+
|
|
|
|
|
+ vi.useRealTimers();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('cleans up on unmount', async () => {
|
|
|
|
|
+ vi.resetModules();
|
|
|
|
|
+ const { useWebSocket } = await import('../../hooks/useWebSocket');
|
|
|
|
|
+
|
|
|
|
|
+ const { unmount } = renderHook(() => useWebSocket(), {
|
|
|
|
|
+ wrapper: createWrapper(queryClient),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const ws = getLatestWs()!;
|
|
|
|
|
+
|
|
|
|
|
+ // Open connection
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ ws.open();
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
- ws.simulateError();
|
|
|
|
|
|
|
+ unmount();
|
|
|
|
|
|
|
|
- expect(onError).toHaveBeenCalled();
|
|
|
|
|
|
|
+ expect(ws.close).toHaveBeenCalled();
|
|
|
});
|
|
});
|
|
|
});
|
|
});
|
|
|
});
|
|
});
|