mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 19:36:28 +02:00
Frontend WebSocket Tests Summary
Expanded the useWebSocket.test.ts from minimal coverage to 20 comprehensive tests covering:
WebSocket Mock Tests (6 tests)
- Creates WebSocket with correct URL
- Starts in CONNECTING state
- Transitions to OPEN state
- Can receive messages
- Can close connection
- Tracks all instances
Hook Connection Tests (2 tests)
- Connects to WebSocket on mount
- Reports connected state when WebSocket opens
Message Handling Tests (9 tests)
- Updates printer status in query cache on printer_status message
- Preserves wifi_signal when new value is null
- Invalidates archives on print_complete message
- Invalidates archives on archive_created message
- Invalidates archives on archive_updated message (new handler for timelapse auto-assignment)
- Ignores pong messages without error
- Handles malformed JSON gracefully
- Handles unknown message types gracefully
sendMessage Tests (2 tests)
- Sends JSON message when connected
- Does not send when disconnected
Reconnection Tests (2 tests)
- Reconnects after connection closes
- Cleans up on unmount
Key Fixes
- Fixed MSW (Mock Service Worker) conflict by:
a. Adding addEventListener/removeEventListener to MockWebSocket class
b. Updating MSW setup to bypass WebSocket requests
c. Properly managing WebSocket mock lifecycle in each test
Test Results:
- Frontend: 137 tests passed (9 test files)
- Backend: 346 tests passed
This commit is contained in:
@@ -2,45 +2,55 @@
|
||||
* Tests for the useWebSocket hook.
|
||||
*
|
||||
* 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 { 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 {
|
||||
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;
|
||||
onclose: ((event: CloseEvent) => void) | null = null;
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: ((event: Event) => void) | null = null;
|
||||
|
||||
url: string;
|
||||
constructor(url: string) {
|
||||
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;
|
||||
if (this.onclose) {
|
||||
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
|
||||
@@ -53,40 +63,51 @@ class MockWebSocket {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to simulate an error
|
||||
simulateError() {
|
||||
if (this.onerror) {
|
||||
this.onerror(new Event('error'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store reference to mock instances
|
||||
let mockWebSocketInstance: MockWebSocket | null = null;
|
||||
// Create test QueryClient
|
||||
function createTestQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
vi.stubGlobal(
|
||||
'WebSocket',
|
||||
vi.fn((url: string) => {
|
||||
mockWebSocketInstance = new MockWebSocket(url);
|
||||
return mockWebSocketInstance;
|
||||
})
|
||||
);
|
||||
// Wrapper with QueryClient for hook testing
|
||||
function createWrapper(queryClient: QueryClient) {
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
children
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function getLatestWs(): MockWebSocket | undefined {
|
||||
return wsInstances[wsInstances.length - 1];
|
||||
}
|
||||
|
||||
describe('useWebSocket hook', () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockWebSocketInstance = null;
|
||||
wsInstances = [];
|
||||
queryClient = createTestQueryClient();
|
||||
// Save original and install mock
|
||||
originalWebSocket = globalThis.WebSocket;
|
||||
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
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', () => {
|
||||
@@ -100,26 +121,23 @@ describe('useWebSocket hook', () => {
|
||||
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 onOpen = vi.fn();
|
||||
ws.onopen = onOpen;
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ws.readyState).toBe(MockWebSocket.OPEN);
|
||||
});
|
||||
ws.open();
|
||||
|
||||
expect(ws.readyState).toBe(MockWebSocket.OPEN);
|
||||
expect(onOpen).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('can receive messages', async () => {
|
||||
it('can receive messages', () => {
|
||||
const ws = new MockWebSocket('ws://test.local/ws');
|
||||
const onMessage = vi.fn();
|
||||
ws.onmessage = onMessage;
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ws.readyState).toBe(MockWebSocket.OPEN);
|
||||
});
|
||||
|
||||
ws.open();
|
||||
ws.simulateMessage({ type: 'status', data: { connected: true } });
|
||||
|
||||
expect(onMessage).toHaveBeenCalled();
|
||||
@@ -136,14 +154,395 @@ describe('useWebSocket hook', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
ws.simulateError();
|
||||
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');
|
||||
|
||||
expect(onError).toHaveBeenCalled();
|
||||
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();
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(ws.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,8 +8,19 @@ import { afterAll, afterEach, beforeAll, vi } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { server } from './mocks/server';
|
||||
|
||||
// Setup MSW server
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
||||
// Setup MSW server - bypass WebSocket requests so our mock handles them
|
||||
beforeAll(() =>
|
||||
server.listen({
|
||||
onUnhandledRequest: (request, print) => {
|
||||
// Allow WebSocket requests to pass through to our mock
|
||||
if (request.url.includes('/ws')) {
|
||||
return;
|
||||
}
|
||||
// Error on other unhandled requests
|
||||
print.error();
|
||||
},
|
||||
})
|
||||
);
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
server.resetHandlers();
|
||||
|
||||
@@ -102,6 +102,11 @@ export function useWebSocket() {
|
||||
queryClient.invalidateQueries({ queryKey: ['archiveStats'] });
|
||||
break;
|
||||
|
||||
case 'archive_updated':
|
||||
// Invalidate archives to refresh (e.g., timelapse attached)
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
// Keepalive response, ignore
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user