Files
backroad/app/react/components/Terminal/Terminal.test.tsx

341 lines
9.6 KiB
TypeScript

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<ReturnType<typeof ws.link>['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<XTerm>;
let mockFitAddonInstance: Partial<FitAddon>;
let mockResizeObserverObserve: ReturnType<typeof vi.fn>;
let mockResizeObserverDisconnect: ReturnType<typeof vi.fn>;
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()
// eslint-disable-next-line prefer-arrow-callback
.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(<Terminal url={TEST_URL} connect={false} />);
expect(vi.mocked(XTerm)).not.toHaveBeenCalled();
});
it('calls onStateChange with connecting immediately when connect=true', () => {
const onStateChange = vi.fn();
render(<Terminal url={TEST_URL} connect onStateChange={onStateChange} />);
expect(onStateChange).toHaveBeenCalledWith('connecting');
});
it('calls onStateChange with connected when socket opens', async () => {
const onStateChange = vi.fn();
render(<Terminal url={TEST_URL} connect onStateChange={onStateChange} />);
// 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(<Terminal url={TEST_URL} connect onStateChange={onStateChange} />);
await waitFor(() => expect(clientConnection).toBeDefined());
clientConnection!.close();
await waitFor(() => {
expect(onStateChange).toHaveBeenCalledWith('disconnected');
});
});
});
describe('terminal initialization', () => {
it('opens on socket open', async () => {
render(<Terminal url={TEST_URL} connect />);
await waitFor(() => {
expect(mockTerminalInstance.open).toHaveBeenCalled();
});
});
it('registers resize observer on container element', async () => {
render(<Terminal url={TEST_URL} connect />);
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
expect(mockResizeObserverObserve).toHaveBeenCalledWith(
expect.any(HTMLElement)
);
});
it('fits terminal when container resizes', async () => {
render(<Terminal url={TEST_URL} connect />);
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(<Terminal url={TEST_URL} connect />);
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(<Terminal url={TEST_URL} connect />);
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(<Terminal url={TEST_URL} connect />);
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(<Terminal url={TEST_URL} connect />);
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(<Terminal url={TEST_URL} connect onResize={onResize} />);
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(
<Terminal url={TEST_URL} connect={false} onResize={onResize} />
);
expect(onResize).not.toHaveBeenCalled();
rerender(<Terminal url={TEST_URL} connect onResize={onResize} />);
await waitFor(() =>
expect(onResize).toHaveBeenCalledWith({ rows: 24, cols: 80 })
);
});
it('calls onResize with terminal dimensions when container resizes', async () => {
const onResize = vi.fn();
render(<Terminal url={TEST_URL} connect onResize={onResize} />);
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(<Terminal url={TEST_URL} connect={false} onResize={onResize} />);
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(<Terminal url={TEST_URL} connect onStateChange={onStateChange} />);
await waitFor(() => {
expect(onStateChange).toHaveBeenCalledWith('disconnected');
});
expect(vi.mocked(notifyError)).not.toHaveBeenCalled();
});
});
});