Files
docker-swarm-termina/frontend/lib/hooks/__tests__/useInteractiveTerminal.test.tsx
Claude f1067813e1 Add comprehensive tests for WebSocket transport configuration
This commit adds tests to catch the WebSocket transport misconfiguration
that caused "Invalid frame header" errors. The original test suite didn't
catch this because it was an infrastructure-level issue, not a code bug.

New Tests Added:

Frontend (frontend/lib/hooks/__tests__/useInteractiveTerminal.test.tsx):
- Verify Socket.IO client uses polling-only transport
- Ensure WebSocket is NOT in transports array
- Validate HTTP URL is used (not WebSocket URL)
- Confirm all event handlers are registered
- Test cleanup on unmount

Backend (backend/tests/test_websocket.py):
- TestSocketIOConfiguration class added
- Verify SocketIO async_mode, ping_timeout, ping_interval
- Confirm CORS is enabled
- Validate /terminal namespace registration

Documentation (TESTING.md):
- Explains why original tests didn't catch this issue
- Documents testing gaps (environment, mocking, integration)
- Provides recommendations for E2E, monitoring, error tracking
- Outlines testing strategy and coverage goals

Why Original Tests Missed This:
1. Environment Gap: Tests run locally where WebSocket works
2. Mock-Based: SocketIOTestClient doesn't simulate proxies/CDNs
3. No Infrastructure Tests: Didn't validate production-like setup

These new tests will catch configuration errors in code, but won't catch
infrastructure issues (Cloudflare blocking, proxy misconfig, etc.). For
those, we recommend E2E tests, synthetic monitoring, and error tracking
as documented in TESTING.md.

https://claude.ai/code/session_mmQs0
2026-02-01 14:11:31 +00:00

233 lines
5.2 KiB
TypeScript

import { renderHook, waitFor } from '@testing-library/react';
import { useInteractiveTerminal } from '../useInteractiveTerminal';
import { io } from 'socket.io-client';
// Mock socket.io-client
jest.mock('socket.io-client');
// Mock xterm
jest.mock('@xterm/xterm', () => ({
Terminal: jest.fn().mockImplementation(() => ({
open: jest.fn(),
write: jest.fn(),
dispose: jest.fn(),
onData: jest.fn(),
loadAddon: jest.fn(),
})),
}));
jest.mock('@xterm/addon-fit', () => ({
FitAddon: jest.fn().mockImplementation(() => ({
fit: jest.fn(),
proposeDimensions: jest.fn(() => ({ cols: 80, rows: 24 })),
})),
}));
// Mock API client
jest.mock('@/lib/api', () => ({
apiClient: {
getToken: jest.fn(() => 'mock-token'),
},
API_BASE_URL: 'http://localhost:5000',
}));
describe('useInteractiveTerminal', () => {
let mockSocket: any;
beforeEach(() => {
// Reset mocks
jest.clearAllMocks();
// Create mock socket
mockSocket = {
on: jest.fn(),
emit: jest.fn(),
disconnect: jest.fn(),
connected: true,
};
(io as jest.Mock).mockReturnValue(mockSocket);
// Mock window
Object.defineProperty(window, 'addEventListener', {
value: jest.fn(),
writable: true,
});
Object.defineProperty(window, 'removeEventListener', {
value: jest.fn(),
writable: true,
});
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should initialize socket.io with polling-only transport', async () => {
const onFallback = jest.fn();
renderHook(() =>
useInteractiveTerminal({
open: true,
containerId: 'test-123',
containerName: 'test-container',
isMobile: false,
onFallback,
})
);
await waitFor(
() => {
expect(io).toHaveBeenCalled();
},
{ timeout: 3000 }
);
// Verify io was called with correct configuration
expect(io).toHaveBeenCalledWith(
'http://localhost:5000/terminal',
expect.objectContaining({
transports: ['polling'],
reconnectionDelayMax: 10000,
timeout: 60000,
forceNew: true,
})
);
});
it('should NOT use websocket transport', async () => {
const onFallback = jest.fn();
renderHook(() =>
useInteractiveTerminal({
open: true,
containerId: 'test-123',
containerName: 'test-container',
isMobile: false,
onFallback,
})
);
await waitFor(
() => {
expect(io).toHaveBeenCalled();
},
{ timeout: 3000 }
);
const ioCall = (io as jest.Mock).mock.calls[0];
const config = ioCall[1];
// Verify websocket is NOT in transports array
expect(config.transports).not.toContain('websocket');
expect(config.transports).toEqual(['polling']);
});
it('should use HTTP URL not WebSocket URL', async () => {
const onFallback = jest.fn();
renderHook(() =>
useInteractiveTerminal({
open: true,
containerId: 'test-123',
containerName: 'test-container',
isMobile: false,
onFallback,
})
);
await waitFor(
() => {
expect(io).toHaveBeenCalled();
},
{ timeout: 3000 }
);
const ioCall = (io as jest.Mock).mock.calls[0];
const url = ioCall[0];
// Should use http:// not ws://
expect(url).toMatch(/^http/);
expect(url).not.toMatch(/^ws/);
expect(url).toBe('http://localhost:5000/terminal');
});
it('should not initialize socket when modal is closed', () => {
const onFallback = jest.fn();
renderHook(() =>
useInteractiveTerminal({
open: false,
containerId: 'test-123',
containerName: 'test-container',
isMobile: false,
onFallback,
})
);
// Socket.IO should not be initialized
expect(io).not.toHaveBeenCalled();
});
it('should register all required socket event handlers', async () => {
const onFallback = jest.fn();
renderHook(() =>
useInteractiveTerminal({
open: true,
containerId: 'test-123',
containerName: 'test-container',
isMobile: false,
onFallback,
})
);
await waitFor(
() => {
expect(mockSocket.on).toHaveBeenCalled();
},
{ timeout: 3000 }
);
// Verify all event handlers are registered
const eventNames = (mockSocket.on as jest.Mock).mock.calls.map(
(call) => call[0]
);
expect(eventNames).toContain('connect');
expect(eventNames).toContain('connect_error');
expect(eventNames).toContain('started');
expect(eventNames).toContain('output');
expect(eventNames).toContain('error');
expect(eventNames).toContain('exit');
expect(eventNames).toContain('disconnect');
});
it('should cleanup socket on unmount', async () => {
const onFallback = jest.fn();
const { unmount } = renderHook(() =>
useInteractiveTerminal({
open: true,
containerId: 'test-123',
containerName: 'test-container',
isMobile: false,
onFallback,
})
);
await waitFor(
() => {
expect(io).toHaveBeenCalled();
},
{ timeout: 3000 }
);
unmount();
await waitFor(() => {
expect(mockSocket.disconnect).toHaveBeenCalled();
});
});
});