import { render, waitFor } from '@testing-library/react'; import { vi } from 'vitest'; import { Terminal as XTerm } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { error as notifyError } from '@/portainer/services/notifications'; import { server, ws } from '@/setup-tests/server'; import { Terminal } from './Terminal'; type WSConnection = Parameters< Parameters['addEventListener']>[1] >[0]; type ClientConnection = WSConnection['client']; const wssLink = ws.link('wss://*/*'); vi.mock('@xterm/xterm', () => ({ Terminal: vi.fn( class { open = vi.fn(); options = {}; focus = vi.fn(); loadAddon = vi.fn(); write = vi.fn(); onData = vi.fn(); onKey = vi.fn(); dispose = vi.fn(); } ), })); vi.mock('@xterm/addon-fit', () => ({ FitAddon: vi.fn( class { fit = vi.fn(); } ), })); vi.mock('@/portainer/services/notifications', () => ({ error: vi.fn(), })); let mockTerminalInstance: Partial; let mockFitAddonInstance: Partial; let mockResizeObserverObserve: ReturnType; let mockResizeObserverDisconnect: ReturnType; let mockResizeObserverCallback: ResizeObserverCallback; beforeEach(() => { vi.clearAllMocks(); mockFitAddonInstance = { fit: vi.fn() }; mockTerminalInstance = { open: vi.fn(), options: {}, focus: vi.fn(), loadAddon: vi.fn(), write: vi.fn(), onData: vi.fn(), onKey: vi.fn(), dispose: vi.fn(), rows: 24, cols: 80, }; vi.mocked(XTerm).mockImplementation(function XTerm(this: XTerm) { Object.assign(this, mockTerminalInstance); }); vi.mocked(FitAddon).mockImplementation(function FitAddon(this: FitAddon) { Object.assign(this, mockFitAddonInstance); }); mockResizeObserverObserve = vi.fn(); mockResizeObserverDisconnect = vi.fn(); globalThis.ResizeObserver = vi .fn() .mockImplementation(function ResizeObserver( this: ResizeObserver, callback: ResizeObserverCallback ) { mockResizeObserverCallback = callback; return { observe: mockResizeObserverObserve, disconnect: mockResizeObserverDisconnect, }; }); server.use( wssLink.addEventListener('connection', ({ client, server }) => { client.addEventListener('message', (event) => { server.send(event.data); }); }) ); }); const TEST_URL = 'wss://localhost:3000/api/test'; describe('Terminal', () => { describe('connection lifecycle', () => { it('does not create terminal when connect=false', () => { render(); expect(vi.mocked(XTerm)).not.toHaveBeenCalled(); }); it('calls onStateChange with connecting immediately when connect=true', () => { const onStateChange = vi.fn(); render(); expect(onStateChange).toHaveBeenCalledWith('connecting'); }); it('calls onStateChange with connected when socket opens', async () => { const onStateChange = vi.fn(); render(); // Wait for the terminal to open (same event that triggers 'connected') await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled()); expect(onStateChange).toHaveBeenCalledWith('connected'); }); it('calls onStateChange with disconnected when socket closes', async () => { const onStateChange = vi.fn(); let clientConnection: ClientConnection | undefined; server.use( wssLink.addEventListener('connection', ({ client }) => { clientConnection = client; }) ); render(); await waitFor(() => expect(clientConnection).toBeDefined()); clientConnection!.close(); await waitFor(() => { expect(onStateChange).toHaveBeenCalledWith('disconnected'); }); }); }); describe('terminal initialization', () => { it('opens on socket open', async () => { render(); await waitFor(() => { expect(mockTerminalInstance.open).toHaveBeenCalled(); }); }); it('registers resize observer on container element', async () => { render(); await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled()); expect(mockResizeObserverObserve).toHaveBeenCalledWith( expect.any(HTMLElement) ); }); it('fits terminal when container resizes', async () => { render(); await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled()); mockResizeObserverCallback([], {} as ResizeObserver); expect(mockFitAddonInstance.fit).toHaveBeenCalled(); }); }); describe('data flow', () => { it('forwards terminal input to WebSocket', async () => { let receivedData: string | undefined; server.use( wssLink.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { receivedData = event.data as string; }); }) ); render(); await waitFor(() => expect(mockTerminalInstance.onData).toHaveBeenCalled() ); const onDataCallback = vi.mocked(mockTerminalInstance.onData!).mock .calls[0]![0] as (data: string) => void; onDataCallback('kubectl get pods'); await waitFor(() => expect(receivedData).toBe('kubectl get pods')); }); it('writes WebSocket messages to terminal as Uint8Array', async () => { server.use( wssLink.addEventListener('connection', ({ client }) => { client.send('server output'); }) ); render(); await waitFor(() => expect(mockTerminalInstance.write).toHaveBeenCalled() ); const [data] = vi.mocked(mockTerminalInstance.write!).mock.calls[0]; expect(new TextDecoder().decode(data as Uint8Array)).toBe( 'server output' ); }); }); describe('cleanup', () => { it('disposes terminal and disconnects resize observer when socket closes', async () => { let clientConnection: ClientConnection | undefined; server.use( wssLink.addEventListener('connection', ({ client }) => { clientConnection = client; }) ); render(); await waitFor(() => expect(clientConnection).toBeDefined()); await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled()); clientConnection!.close(); await waitFor(() => expect(mockTerminalInstance.dispose).toHaveBeenCalled() ); expect(mockResizeObserverDisconnect).toHaveBeenCalled(); }); it('disposes terminal and disconnects resize observer on unmount', async () => { let clientConnection: ClientConnection | undefined; server.use( wssLink.addEventListener('connection', ({ client }) => { clientConnection = client; }) ); const { unmount } = render(); await waitFor(() => expect(clientConnection).toBeDefined()); await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled()); unmount(); expect(mockTerminalInstance.dispose).toHaveBeenCalled(); expect(mockResizeObserverDisconnect).toHaveBeenCalled(); }); }); describe('onResize', () => { it('calls onResize with terminal dimensions after initial connection', async () => { const onResize = vi.fn(); render(); await waitFor(() => expect(onResize).toHaveBeenCalledWith({ rows: 24, cols: 80 }) ); }); it('calls onResize with terminal dimensions when connect changes to true', async () => { const onResize = vi.fn(); const { rerender } = render( ); expect(onResize).not.toHaveBeenCalled(); rerender(); await waitFor(() => expect(onResize).toHaveBeenCalledWith({ rows: 24, cols: 80 }) ); }); // mockResizeObserver doesn't trigger resize it.todo( 'calls onResize with terminal dimensions when container resizes', async () => { const onResize = vi.fn(); render(); await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled() ); onResize.mockClear(); mockResizeObserverCallback([], {} as ResizeObserver); expect(onResize).toHaveBeenCalledWith({ rows: 24, cols: 80 }); } ); it('does not call onResize when connect=false', () => { const onResize = vi.fn(); render(); expect(onResize).not.toHaveBeenCalled(); }); }); describe('error handling', () => { it('does not show error notification when socket closes cleanly', async () => { server.use( wssLink.addEventListener('connection', ({ client }) => { client.close(); }) ); const onStateChange = vi.fn(); render(); await waitFor(() => { expect(onStateChange).toHaveBeenCalledWith('disconnected'); }); expect(vi.mocked(notifyError)).not.toHaveBeenCalled(); }); }); });