mirror of
https://github.com/johndoe6345789/docker-swarm-termina.git
synced 2026-04-24 13:45:01 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
26
frontend/components/Dashboard/__tests__/EmptyState.test.tsx
Normal file
26
frontend/components/Dashboard/__tests__/EmptyState.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
78
frontend/components/__tests__/LoginForm.test.tsx
Normal file
78
frontend/components/__tests__/LoginForm.test.tsx
Normal 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
27
frontend/jest.config.js
Normal 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
28
frontend/jest.setup.js
Normal 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()
|
||||
69
frontend/lib/hooks/__tests__/useAuthRedirect.test.tsx
Normal file
69
frontend/lib/hooks/__tests__/useAuthRedirect.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
90
frontend/lib/hooks/__tests__/useLoginForm.test.tsx
Normal file
90
frontend/lib/hooks/__tests__/useLoginForm.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
61
frontend/lib/hooks/__tests__/useTerminalModal.test.tsx
Normal file
61
frontend/lib/hooks/__tests__/useTerminalModal.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
134
frontend/lib/store/__tests__/authSlice.test.ts
Normal file
134
frontend/lib/store/__tests__/authSlice.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
98
frontend/lib/utils/__tests__/terminal.test.tsx
Normal file
98
frontend/lib/utils/__tests__/terminal.test.tsx
Normal 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:/#');
|
||||
});
|
||||
});
|
||||
4279
frontend/package-lock.json
generated
4279
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user