Add comprehensive unit testing infrastructure

Install testing dependencies:
- Jest and jest-environment-jsdom for test runner
- React Testing Library for component testing
- @testing-library/user-event for user interaction simulation
- @types/jest for TypeScript support

Configure Jest:
- Next.js Jest configuration with jsdom environment
- Mock window.matchMedia, localStorage, and fetch
- Setup test paths and coverage collection

Add test coverage:
- Utility functions (terminal formatPrompt and highlightCommand)
- Redux store (authSlice async thunks and reducers)
- Custom hooks (useLoginForm, useAuthRedirect, useTerminalModal)
- React components (LoginForm, TerminalHeader, ContainerHeader, ContainerInfo, EmptyState)

Test results: 59 tests passing across 10 test suites

https://claude.ai/code/session_G4kZm
This commit is contained in:
Claude
2026-01-30 23:28:09 +00:00
parent e97b50a916
commit 42c1d4737f
14 changed files with 5102 additions and 1 deletions

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import ContainerHeader from '../ContainerHeader';
describe('ContainerHeader', () => {
it('renders container name', () => {
render(
<ContainerHeader name="test-container" image="nginx:latest" status="running" />
);
expect(screen.getByText('test-container')).toBeInTheDocument();
});
it('renders container image', () => {
render(
<ContainerHeader name="test-container" image="nginx:latest" status="running" />
);
expect(screen.getByText('nginx:latest')).toBeInTheDocument();
});
it('renders status chip with correct label', () => {
render(
<ContainerHeader name="test-container" image="nginx:latest" status="running" />
);
expect(screen.getByText('running')).toBeInTheDocument();
});
it('applies success color for running status', () => {
const { container } = render(
<ContainerHeader name="test-container" image="nginx:latest" status="running" />
);
const statusChip = screen.getByText('running').closest('.MuiChip-root');
expect(statusChip).toHaveClass('MuiChip-colorSuccess');
});
it('applies default color for stopped status', () => {
const { container } = render(
<ContainerHeader name="test-container" image="nginx:latest" status="stopped" />
);
const statusChip = screen.getByText('stopped').closest('.MuiChip-root');
expect(statusChip).toHaveClass('MuiChip-colorDefault');
});
it('applies warning color for paused status', () => {
const { container } = render(
<ContainerHeader name="test-container" image="nginx:latest" status="paused" />
);
const statusChip = screen.getByText('paused').closest('.MuiChip-root');
expect(statusChip).toHaveClass('MuiChip-colorWarning');
});
it('renders play icon for running containers', () => {
const { container } = render(
<ContainerHeader name="test-container" image="nginx:latest" status="running" />
);
const playIcon = container.querySelector('[data-testid="PlayArrowIcon"]');
expect(playIcon).toBeInTheDocument();
});
it('does not render play icon for stopped containers', () => {
const { container } = render(
<ContainerHeader name="test-container" image="nginx:latest" status="stopped" />
);
const playIcon = container.querySelector('[data-testid="PlayArrowIcon"]');
expect(playIcon).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import ContainerInfo from '../ContainerInfo';
describe('ContainerInfo', () => {
it('renders container ID label', () => {
render(<ContainerInfo id="abc123def456" uptime="2 hours" />);
expect(screen.getByText(/container id/i)).toBeInTheDocument();
});
it('renders container ID value', () => {
render(<ContainerInfo id="abc123def456" uptime="2 hours" />);
expect(screen.getByText('abc123def456')).toBeInTheDocument();
});
it('renders uptime label', () => {
render(<ContainerInfo id="abc123def456" uptime="2 hours" />);
expect(screen.getByText(/uptime/i)).toBeInTheDocument();
});
it('renders uptime value', () => {
render(<ContainerInfo id="abc123def456" uptime="2 hours" />);
expect(screen.getByText('2 hours')).toBeInTheDocument();
});
it('renders different uptime formats correctly', () => {
const { rerender } = render(<ContainerInfo id="abc123" uptime="5 minutes" />);
expect(screen.getByText('5 minutes')).toBeInTheDocument();
rerender(<ContainerInfo id="abc123" uptime="3 days" />);
expect(screen.getByText('3 days')).toBeInTheDocument();
rerender(<ContainerInfo id="abc123" uptime="1 month" />);
expect(screen.getByText('1 month')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import EmptyState from '../EmptyState';
describe('EmptyState', () => {
it('renders no containers message', () => {
render(<EmptyState />);
expect(screen.getByText(/no active containers/i)).toBeInTheDocument();
});
it('renders descriptive message', () => {
render(<EmptyState />);
expect(
screen.getByText(/there are currently no running containers to display/i)
).toBeInTheDocument();
});
it('renders inventory icon', () => {
const { container } = render(<EmptyState />);
const icon = container.querySelector('[data-testid="Inventory2Icon"]');
expect(icon).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,88 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import TerminalHeader from '../TerminalHeader';
describe('TerminalHeader', () => {
const mockOnClose = jest.fn();
const mockOnModeChange = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('renders container name', () => {
render(
<TerminalHeader
containerName="test-container"
mode="interactive"
interactiveFailed={false}
onModeChange={mockOnModeChange}
onClose={mockOnClose}
/>
);
expect(screen.getByText(/Terminal - test-container/i)).toBeInTheDocument();
});
it('renders interactive and simple mode buttons', () => {
render(
<TerminalHeader
containerName="test-container"
mode="interactive"
interactiveFailed={false}
onModeChange={mockOnModeChange}
onClose={mockOnClose}
/>
);
expect(screen.getByText('Interactive')).toBeInTheDocument();
expect(screen.getByText('Simple')).toBeInTheDocument();
});
it('calls onClose when close button is clicked', () => {
render(
<TerminalHeader
containerName="test-container"
mode="interactive"
interactiveFailed={false}
onModeChange={mockOnModeChange}
onClose={mockOnClose}
/>
);
const closeButton = screen.getByRole('button', { name: '' });
fireEvent.click(closeButton);
expect(mockOnClose).toHaveBeenCalled();
});
it('shows warning icon when interactive mode failed', () => {
const { container } = render(
<TerminalHeader
containerName="test-container"
mode="simple"
interactiveFailed={true}
onModeChange={mockOnModeChange}
onClose={mockOnClose}
/>
);
const warningIcon = container.querySelector('[data-testid="WarningIcon"]');
expect(warningIcon).toBeInTheDocument();
});
it('applies correct mode selection', () => {
render(
<TerminalHeader
containerName="test-container"
mode="simple"
interactiveFailed={false}
onModeChange={mockOnModeChange}
onClose={mockOnClose}
/>
);
const simpleButton = screen.getByText('Simple').closest('button');
expect(simpleButton).toHaveClass('Mui-selected');
});
});

View File

@@ -0,0 +1,78 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import authReducer from '@/lib/store/authSlice';
import LoginForm from '../LoginForm';
jest.mock('next/navigation', () => ({
useRouter: jest.fn(() => ({
push: jest.fn(),
})),
}));
const createMockStore = (loading = false) =>
configureStore({
reducer: {
auth: authReducer,
},
preloadedState: {
auth: {
isAuthenticated: false,
loading,
username: null,
error: null,
},
},
});
const renderWithProvider = (component: React.ReactElement, loading = false) => {
return render(<Provider store={createMockStore(loading)}>{component}</Provider>);
};
describe('LoginForm', () => {
it('renders login form elements', () => {
renderWithProvider(<LoginForm />);
expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /access dashboard/i })).toBeInTheDocument();
});
it('updates username input on change', () => {
renderWithProvider(<LoginForm />);
const usernameInput = screen.getByLabelText(/username/i) as HTMLInputElement;
fireEvent.change(usernameInput, { target: { value: 'testuser' } });
expect(usernameInput.value).toBe('testuser');
});
it('updates password input on change', () => {
renderWithProvider(<LoginForm />);
const passwordInput = screen.getByLabelText(/password/i) as HTMLInputElement;
fireEvent.change(passwordInput, { target: { value: 'testpass' } });
expect(passwordInput.value).toBe('testpass');
});
it('shows loading text when loading', () => {
renderWithProvider(<LoginForm />, true);
expect(screen.getByRole('button', { name: /logging in/i })).toBeInTheDocument();
});
it('password input is type password', () => {
renderWithProvider(<LoginForm />);
const passwordInput = screen.getByLabelText(/password/i) as HTMLInputElement;
expect(passwordInput.type).toBe('password');
});
it('shows helper text with default credentials', () => {
renderWithProvider(<LoginForm />);
expect(screen.getByText(/default: admin \/ admin123/i)).toBeInTheDocument();
});
});

27
frontend/jest.config.js Normal file
View File

@@ -0,0 +1,27 @@
const nextJest = require('next/jest')
const createJestConfig = nextJest({
dir: './',
})
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
testMatch: [
'**/__tests__/**/*.[jt]s?(x)',
'**/?(*.)+(spec|test).[jt]s?(x)',
],
collectCoverageFrom: [
'lib/**/*.{js,jsx,ts,tsx}',
'components/**/*.{js,jsx,ts,tsx}',
'app/**/*.{js,jsx,ts,tsx}',
'!**/*.d.ts',
'!**/node_modules/**',
'!**/.next/**',
],
}
module.exports = createJestConfig(customJestConfig)

28
frontend/jest.setup.js Normal file
View File

@@ -0,0 +1,28 @@
import '@testing-library/jest-dom'
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
})
// Mock localStorage
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
}
global.localStorage = localStorageMock
// Mock fetch
global.fetch = jest.fn()

View File

@@ -0,0 +1,69 @@
import { renderHook } from '@testing-library/react';
import { useRouter } from 'next/navigation';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import authReducer from '@/lib/store/authSlice';
import { useAuthRedirect } from '../useAuthRedirect';
jest.mock('next/navigation', () => ({
useRouter: jest.fn(),
}));
const createMockStore = (isAuthenticated: boolean) =>
configureStore({
reducer: {
auth: authReducer,
},
preloadedState: {
auth: {
isAuthenticated,
loading: false,
username: isAuthenticated ? 'testuser' : null,
error: null,
},
},
});
describe('useAuthRedirect', () => {
const mockPush = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(useRouter as jest.Mock).mockReturnValue({
push: mockPush,
});
});
it('redirects to dashboard when authenticated and redirectTo is dashboard', () => {
const store = createMockStore(true);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<Provider store={store}>{children}</Provider>
);
renderHook(() => useAuthRedirect('/dashboard'), { wrapper });
expect(mockPush).toHaveBeenCalledWith('/dashboard');
});
it('redirects to login when not authenticated and redirectTo is /', () => {
const store = createMockStore(false);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<Provider store={store}>{children}</Provider>
);
renderHook(() => useAuthRedirect('/'), { wrapper });
expect(mockPush).toHaveBeenCalledWith('/');
});
it('does not redirect when authenticated but redirectTo is /', () => {
const store = createMockStore(true);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<Provider store={store}>{children}</Provider>
);
renderHook(() => useAuthRedirect('/'), { wrapper });
expect(mockPush).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,90 @@
import { renderHook, act } from '@testing-library/react';
import { useRouter } from 'next/navigation';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import authReducer from '@/lib/store/authSlice';
import { useLoginForm } from '../useLoginForm';
jest.mock('next/navigation', () => ({
useRouter: jest.fn(),
}));
const createMockStore = () =>
configureStore({
reducer: {
auth: authReducer,
},
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<Provider store={createMockStore()}>{children}</Provider>
);
describe('useLoginForm', () => {
const mockPush = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(useRouter as jest.Mock).mockReturnValue({
push: mockPush,
});
});
it('initializes with empty username and password', () => {
const { result } = renderHook(() => useLoginForm(), { wrapper });
expect(result.current.username).toBe('');
expect(result.current.password).toBe('');
expect(result.current.error).toBeNull();
});
it('updates username when setUsername is called', () => {
const { result } = renderHook(() => useLoginForm(), { wrapper });
act(() => {
result.current.setUsername('testuser');
});
expect(result.current.username).toBe('testuser');
});
it('updates password when setPassword is called', () => {
const { result } = renderHook(() => useLoginForm(), { wrapper });
act(() => {
result.current.setPassword('testpass');
});
expect(result.current.password).toBe('testpass');
});
it('handles form submission', async () => {
const { result } = renderHook(() => useLoginForm(), { wrapper });
const mockEvent = {
preventDefault: jest.fn(),
} as unknown as React.FormEvent;
act(() => {
result.current.setUsername('testuser');
result.current.setPassword('testpass');
});
await act(async () => {
await result.current.handleSubmit(mockEvent);
});
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
it('returns loading state', () => {
const { result } = renderHook(() => useLoginForm(), { wrapper });
expect(result.current.loading).toBeDefined();
});
it('returns isShaking state', () => {
const { result } = renderHook(() => useLoginForm(), { wrapper });
expect(result.current.isShaking).toBe(false);
});
});

View File

@@ -0,0 +1,61 @@
import { renderHook, act } from '@testing-library/react';
import { useTerminalModal } from '../useTerminalModal';
describe('useTerminalModal', () => {
it('initializes with modal closed and no container selected', () => {
const { result } = renderHook(() => useTerminalModal());
expect(result.current.isTerminalOpen).toBe(false);
expect(result.current.selectedContainer).toBeNull();
});
it('opens modal with selected container', () => {
const { result } = renderHook(() => useTerminalModal());
const mockContainer = { id: '123', name: 'test-container' } as any;
act(() => {
result.current.openTerminal(mockContainer);
});
expect(result.current.isTerminalOpen).toBe(true);
expect(result.current.selectedContainer).toEqual(mockContainer);
});
it('closes modal and eventually clears selected container', async () => {
const { result } = renderHook(() => useTerminalModal());
const mockContainer = { id: '123', name: 'test-container' } as any;
act(() => {
result.current.openTerminal(mockContainer);
});
expect(result.current.isTerminalOpen).toBe(true);
act(() => {
result.current.closeTerminal();
});
expect(result.current.isTerminalOpen).toBe(false);
});
it('handles multiple open and close cycles', () => {
const { result } = renderHook(() => useTerminalModal());
const container1 = { id: '123', name: 'container1' } as any;
const container2 = { id: '456', name: 'container2' } as any;
act(() => {
result.current.openTerminal(container1);
});
expect(result.current.selectedContainer).toEqual(container1);
act(() => {
result.current.closeTerminal();
});
expect(result.current.isTerminalOpen).toBe(false);
act(() => {
result.current.openTerminal(container2);
});
expect(result.current.selectedContainer).toEqual(container2);
});
});

View File

@@ -0,0 +1,134 @@
import { configureStore } from '@reduxjs/toolkit';
import authReducer, {
login,
logout,
initAuth,
setUnauthenticated,
} from '../authSlice';
import * as apiClient from '@/lib/api';
jest.mock('@/lib/api');
describe('authSlice', () => {
let store: ReturnType<typeof configureStore>;
beforeEach(() => {
store = configureStore({
reducer: {
auth: authReducer,
},
});
jest.clearAllMocks();
localStorage.clear();
});
describe('initial state', () => {
it('has correct initial state', () => {
const state = store.getState().auth;
expect(state).toEqual({
isAuthenticated: false,
loading: true,
username: null,
error: null,
});
});
});
describe('setUnauthenticated', () => {
it('sets auth state to unauthenticated', () => {
store.dispatch(setUnauthenticated());
const state = store.getState().auth;
expect(state.isAuthenticated).toBe(false);
expect(state.username).toBeNull();
});
});
describe('login async thunk', () => {
it('handles successful login', async () => {
const mockLoginResponse = { success: true, username: 'testuser' };
(apiClient.apiClient.login as jest.Mock).mockResolvedValue(mockLoginResponse);
await store.dispatch(login({ username: 'testuser', password: 'password' }));
const state = store.getState().auth;
expect(state.isAuthenticated).toBe(true);
expect(state.username).toBe('testuser');
expect(state.loading).toBe(false);
});
it('handles login failure', async () => {
(apiClient.apiClient.login as jest.Mock).mockRejectedValue(
new Error('Invalid credentials')
);
await store.dispatch(login({ username: 'testuser', password: 'wrong' }));
const state = store.getState().auth;
expect(state.isAuthenticated).toBe(false);
expect(state.username).toBeNull();
expect(state.loading).toBe(false);
expect(state.error).toBeTruthy();
});
it('sets loading state during login', () => {
(apiClient.apiClient.login as jest.Mock).mockImplementation(
() => new Promise(() => {})
);
store.dispatch(login({ username: 'testuser', password: 'password' }));
const state = store.getState().auth;
expect(state.loading).toBe(true);
});
});
describe('logout async thunk', () => {
it('clears authentication state on logout', async () => {
(apiClient.apiClient.logout as jest.Mock).mockResolvedValue({});
await store.dispatch(logout());
const state = store.getState().auth;
expect(state.isAuthenticated).toBe(false);
expect(state.username).toBeNull();
});
});
describe('initAuth async thunk', () => {
it('restores auth state when token is valid', async () => {
(apiClient.apiClient.getToken as jest.Mock).mockReturnValue('valid-token');
(apiClient.apiClient.getUsername as jest.Mock).mockReturnValue('testuser');
(apiClient.apiClient.getContainers as jest.Mock).mockResolvedValue([]);
await store.dispatch(initAuth());
const state = store.getState().auth;
expect(state.isAuthenticated).toBe(true);
expect(state.username).toBe('testuser');
});
it('clears invalid token', async () => {
(apiClient.apiClient.getToken as jest.Mock).mockReturnValue('invalid-token');
(apiClient.apiClient.getContainers as jest.Mock).mockRejectedValue(
new Error('Unauthorized')
);
await store.dispatch(initAuth());
const state = store.getState().auth;
expect(state.isAuthenticated).toBe(false);
expect(state.username).toBeNull();
expect(apiClient.apiClient.setToken).toHaveBeenCalledWith(null);
});
it('handles no token present', async () => {
(apiClient.apiClient.getToken as jest.Mock).mockReturnValue(null);
await store.dispatch(initAuth());
const state = store.getState().auth;
expect(state.isAuthenticated).toBe(false);
expect(state.username).toBeNull();
});
});
});

View File

@@ -0,0 +1,98 @@
import React from 'react';
import { render } from '@testing-library/react';
import { formatPrompt, highlightCommand } from '../terminal';
import { OutputLine } from '@/lib/interfaces/terminal';
describe('formatPrompt', () => {
it('formats a simple prompt correctly', () => {
const result = formatPrompt('test-container', '/home/user');
expect(result).toBe('root@test-container:/home/user#');
});
it('truncates long directory paths', () => {
const longPath = '/very/long/directory/path/that/is/over/thirty/characters';
const result = formatPrompt('test-container', longPath);
expect(result).toContain('...');
expect(result).toContain('characters');
expect(result).toMatch(/^root@test-container:\.\.\.\/characters#$/);
});
it('handles root directory', () => {
const result = formatPrompt('test-container', '/');
expect(result).toBe('root@test-container:/#');
});
it('handles container names with special characters', () => {
const result = formatPrompt('my-container-123', '/app');
expect(result).toBe('root@my-container-123:/app#');
});
});
describe('highlightCommand', () => {
it('renders command output with proper formatting', () => {
const line: OutputLine = {
type: 'command',
content: 'ls -la',
workdir: '/home/user',
};
const { container } = render(highlightCommand(line, 'test-container'));
expect(container.textContent).toContain('root@test-container:/home/user#');
expect(container.textContent).toContain('ls');
expect(container.textContent).toContain('-la');
});
it('renders command with no arguments', () => {
const line: OutputLine = {
type: 'command',
content: 'pwd',
workdir: '/app',
};
const { container } = render(highlightCommand(line, 'test-container'));
expect(container.textContent).toContain('pwd');
});
it('renders error output with red color', () => {
const line: OutputLine = {
type: 'error',
content: 'Error: command not found',
};
const { container } = render(highlightCommand(line, 'test-container'));
const errorDiv = container.querySelector('div');
expect(errorDiv).toHaveStyle({ color: '#FF5555' });
expect(container.textContent).toContain('Error: command not found');
});
it('renders regular output', () => {
const line: OutputLine = {
type: 'output',
content: 'Hello world',
};
const { container } = render(highlightCommand(line, 'test-container'));
expect(container.textContent).toContain('Hello world');
});
it('preserves whitespace in output', () => {
const line: OutputLine = {
type: 'output',
content: 'Line 1\nLine 2',
};
const { container } = render(highlightCommand(line, 'test-container'));
const outputDiv = container.querySelector('div');
expect(outputDiv).toHaveStyle({ whiteSpace: 'pre-wrap' });
});
it('uses default workdir when not provided', () => {
const line: OutputLine = {
type: 'command',
content: 'echo test',
};
const { container } = render(highlightCommand(line, 'test-container'));
expect(container.textContent).toContain('root@test-container:/#');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,10 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"dependencies": {
"@emotion/react": "^11.14.0",
@@ -24,11 +27,17 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.1",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"tailwindcss": "^4",
"typescript": "^5"
}