-
Notifications
You must be signed in to change notification settings - Fork 13
feat(frontend): add Vitest testing infrastructure with React Testing … #266
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,236 @@ | ||
| import { renderHook, waitFor, act } from '@testing-library/react'; | ||
| import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; | ||
| import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; | ||
| import { ReactNode } from 'react'; | ||
| import { useAttemptExecution } from '../useAttemptExecution'; | ||
| import { attemptsApi, executionProcessesApi } from '@/lib/api'; | ||
| import type { ExecutionProcess } from 'shared/types'; | ||
|
|
||
| // Mock the API module | ||
| vi.mock('@/lib/api', () => ({ | ||
| attemptsApi: { | ||
| stop: vi.fn(), | ||
| }, | ||
| executionProcessesApi: { | ||
| getDetails: vi.fn(), | ||
| }, | ||
| })); | ||
|
|
||
| // Mock the store | ||
| const mockSetIsStopping = vi.fn(); | ||
| vi.mock('@/stores/useTaskDetailsUiStore', () => ({ | ||
| useTaskStopping: () => ({ | ||
| isStopping: false, | ||
| setIsStopping: mockSetIsStopping, | ||
| }), | ||
| })); | ||
|
|
||
| // Mock execution processes context | ||
| const mockContextValue = { | ||
| executionProcessesVisible: [] as ExecutionProcess[], | ||
| isAttemptRunningVisible: false, | ||
| isLoading: false, | ||
| }; | ||
|
|
||
| vi.mock('@/contexts/ExecutionProcessesContext', () => ({ | ||
| useExecutionProcessesContext: () => mockContextValue, | ||
| })); | ||
|
|
||
| // Helper to create wrapper with QueryClient | ||
| const createWrapper = () => { | ||
| const queryClient = new QueryClient({ | ||
| defaultOptions: { | ||
| queries: { retry: false }, | ||
| mutations: { retry: false }, | ||
| }, | ||
| }); | ||
|
|
||
| return function Wrapper({ children }: { children: ReactNode }) { | ||
| return ( | ||
| <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> | ||
| ); | ||
| }; | ||
| }; | ||
|
|
||
| // Mock execution process data | ||
| const createMockProcess = ( | ||
| overrides: Partial<ExecutionProcess> = {} | ||
| ): ExecutionProcess => ({ | ||
| id: `process-${Math.random().toString(36).slice(2)}`, | ||
| task_attempt_id: 'attempt-123', | ||
| status: 'running', | ||
| run_reason: 'codingagent', | ||
| variant: null, | ||
| pid: 12345, | ||
| prompt: 'Test prompt', | ||
| exit_code: null, | ||
| dropped: false, | ||
| created_at: new Date().toISOString(), | ||
| updated_at: new Date().toISOString(), | ||
| ...overrides, | ||
| }); | ||
|
|
||
| describe('useAttemptExecution', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| // Reset mock context to default values | ||
| mockContextValue.executionProcessesVisible = []; | ||
| mockContextValue.isAttemptRunningVisible = false; | ||
| mockContextValue.isLoading = false; | ||
| }); | ||
|
|
||
| describe('initial state', () => { | ||
| it('should return empty processes when context has none', () => { | ||
| const { result } = renderHook( | ||
| () => useAttemptExecution('attempt-123', 'task-123'), | ||
| { wrapper: createWrapper() } | ||
| ); | ||
|
|
||
| expect(result.current.processes).toEqual([]); | ||
| expect(result.current.attemptData.processes).toEqual([]); | ||
| expect(result.current.isAttemptRunning).toBe(false); | ||
| }); | ||
|
|
||
| it('should return loading state from context', () => { | ||
| mockContextValue.isLoading = true; | ||
|
|
||
| const { result } = renderHook( | ||
| () => useAttemptExecution('attempt-123', 'task-123'), | ||
| { wrapper: createWrapper() } | ||
| ); | ||
|
|
||
| expect(result.current.isLoading).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| describe('with execution processes', () => { | ||
| it('should return processes from context', () => { | ||
| const mockProcesses = [ | ||
| createMockProcess({ id: 'process-1' }), | ||
| createMockProcess({ id: 'process-2' }), | ||
| ]; | ||
| mockContextValue.executionProcessesVisible = mockProcesses; | ||
| mockContextValue.isAttemptRunningVisible = true; | ||
|
|
||
| const { result } = renderHook( | ||
| () => useAttemptExecution('attempt-123', 'task-123'), | ||
| { wrapper: createWrapper() } | ||
| ); | ||
|
|
||
| expect(result.current.processes).toHaveLength(2); | ||
| expect(result.current.isAttemptRunning).toBe(true); | ||
| }); | ||
|
|
||
| it('should fetch details for setup script processes', async () => { | ||
| const setupProcess = createMockProcess({ | ||
| id: 'setup-1', | ||
| run_reason: 'setupscript', | ||
| }); | ||
| const detailedProcess = { ...setupProcess, prompt: 'Detailed prompt' }; | ||
|
|
||
| mockContextValue.executionProcessesVisible = [setupProcess]; | ||
| (executionProcessesApi.getDetails as Mock).mockResolvedValue( | ||
| detailedProcess | ||
| ); | ||
|
|
||
| const { result } = renderHook( | ||
| () => useAttemptExecution('attempt-123', 'task-123'), | ||
| { wrapper: createWrapper() } | ||
| ); | ||
|
|
||
| // Wait for the query to complete | ||
| await waitFor(() => { | ||
| expect(executionProcessesApi.getDetails).toHaveBeenCalledWith( | ||
| 'setup-1' | ||
| ); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe('stopExecution', () => { | ||
| it('should call attemptsApi.stop when attempt is running', async () => { | ||
| mockContextValue.isAttemptRunningVisible = true; | ||
| (attemptsApi.stop as Mock).mockResolvedValue(undefined); | ||
|
|
||
| const { result } = renderHook( | ||
| () => useAttemptExecution('attempt-123', 'task-123'), | ||
| { wrapper: createWrapper() } | ||
| ); | ||
|
|
||
| await act(async () => { | ||
| await result.current.stopExecution(); | ||
| }); | ||
|
|
||
| expect(attemptsApi.stop).toHaveBeenCalledWith('attempt-123'); | ||
| expect(mockSetIsStopping).toHaveBeenCalledWith(true); | ||
| expect(mockSetIsStopping).toHaveBeenCalledWith(false); | ||
| }); | ||
|
|
||
| it('should not call stop when attempt is not running', async () => { | ||
| mockContextValue.isAttemptRunningVisible = false; | ||
|
|
||
| const { result } = renderHook( | ||
| () => useAttemptExecution('attempt-123', 'task-123'), | ||
| { wrapper: createWrapper() } | ||
| ); | ||
|
|
||
| await act(async () => { | ||
| await result.current.stopExecution(); | ||
| }); | ||
|
|
||
| expect(attemptsApi.stop).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('should not call stop when attemptId is undefined', async () => { | ||
| mockContextValue.isAttemptRunningVisible = true; | ||
|
|
||
| const { result } = renderHook( | ||
| () => useAttemptExecution(undefined, 'task-123'), | ||
| { wrapper: createWrapper() } | ||
| ); | ||
|
|
||
| await act(async () => { | ||
| await result.current.stopExecution(); | ||
| }); | ||
|
|
||
| expect(attemptsApi.stop).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('should handle stop error', async () => { | ||
| mockContextValue.isAttemptRunningVisible = true; | ||
| const error = new Error('Failed to stop'); | ||
| (attemptsApi.stop as Mock).mockRejectedValue(error); | ||
|
|
||
| const { result } = renderHook( | ||
| () => useAttemptExecution('attempt-123', 'task-123'), | ||
| { wrapper: createWrapper() } | ||
| ); | ||
|
|
||
| await expect( | ||
| act(async () => { | ||
| await result.current.stopExecution(); | ||
| }) | ||
| ).rejects.toThrow('Failed to stop'); | ||
|
|
||
| // Should still set isStopping back to false on error | ||
| expect(mockSetIsStopping).toHaveBeenLastCalledWith(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe('attemptData', () => { | ||
| it('should build attemptData with processes and empty details when no setup processes', () => { | ||
| const mockProcesses = [ | ||
| createMockProcess({ run_reason: 'codingagent' }), | ||
| ]; | ||
| mockContextValue.executionProcessesVisible = mockProcesses; | ||
|
|
||
| const { result } = renderHook( | ||
| () => useAttemptExecution('attempt-123', 'task-123'), | ||
| { wrapper: createWrapper() } | ||
| ); | ||
|
|
||
| expect(result.current.attemptData.processes).toEqual(mockProcesses); | ||
| expect(result.current.attemptData.runningProcessDetails).toEqual({}); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| import { renderHook } from '@testing-library/react'; | ||
| import { describe, it, expect } from 'vitest'; | ||
| import { useFilteredTasks } from '../useFilteredTasks'; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new Useful? React with 👍 / 👎. |
||
| import type { Task, TaskStatus } from 'shared/types'; | ||
|
|
||
| // Helper to create mock tasks | ||
| const createMockTask = (overrides: Partial<Task> = {}): Task => ({ | ||
| id: `task-${Math.random().toString(36).slice(2)}`, | ||
| project_id: 'project-1', | ||
| title: 'Test Task', | ||
| description: 'Test description', | ||
| status: 'todo', | ||
| parent_task_attempt: null, | ||
| dev_server_id: null, | ||
| created_at: new Date().toISOString(), | ||
| updated_at: new Date().toISOString(), | ||
| ...overrides, | ||
| }); | ||
|
|
||
| describe('useFilteredTasks', () => { | ||
| it('should return empty array when tasks is empty', () => { | ||
| const { result } = renderHook(() => useFilteredTasks([], 'todo')); | ||
| expect(result.current).toEqual([]); | ||
| }); | ||
|
|
||
| it('should filter tasks by status', () => { | ||
| const tasks: Task[] = [ | ||
| createMockTask({ id: '1', status: 'todo' }), | ||
| createMockTask({ id: '2', status: 'inprogress' }), | ||
| createMockTask({ id: '3', status: 'todo' }), | ||
| createMockTask({ id: '4', status: 'done' }), | ||
| ]; | ||
|
|
||
| const { result } = renderHook(() => useFilteredTasks(tasks, 'todo')); | ||
|
|
||
| expect(result.current).toHaveLength(2); | ||
| expect(result.current.map((t) => t.id)).toEqual(['1', '3']); | ||
| }); | ||
|
|
||
| it('should filter out agent status tasks', () => { | ||
| const tasks: Task[] = [ | ||
| createMockTask({ id: '1', status: 'agent' }), | ||
| createMockTask({ id: '2', status: 'todo' }), | ||
| ]; | ||
|
|
||
| const { result } = renderHook(() => useFilteredTasks(tasks, 'agent')); | ||
|
|
||
| // Agent tasks should be filtered out even when filtering for 'agent' status | ||
| expect(result.current).toHaveLength(0); | ||
| }); | ||
|
|
||
| it('should return all tasks matching the status when no agent tasks', () => { | ||
| const tasks: Task[] = [ | ||
| createMockTask({ id: '1', status: 'inprogress' }), | ||
| createMockTask({ id: '2', status: 'inprogress' }), | ||
| createMockTask({ id: '3', status: 'inprogress' }), | ||
| ]; | ||
|
|
||
| const { result } = renderHook(() => useFilteredTasks(tasks, 'inprogress')); | ||
|
|
||
| expect(result.current).toHaveLength(3); | ||
| }); | ||
|
|
||
| it('should update when tasks change', () => { | ||
| const initialTasks: Task[] = [createMockTask({ id: '1', status: 'todo' })]; | ||
|
|
||
| const { result, rerender } = renderHook( | ||
| ({ tasks, status }) => useFilteredTasks(tasks, status), | ||
| { initialProps: { tasks: initialTasks, status: 'todo' as TaskStatus } } | ||
| ); | ||
|
|
||
| expect(result.current).toHaveLength(1); | ||
|
|
||
| const updatedTasks: Task[] = [ | ||
| ...initialTasks, | ||
| createMockTask({ id: '2', status: 'todo' }), | ||
| ]; | ||
|
|
||
| rerender({ tasks: updatedTasks, status: 'todo' }); | ||
|
|
||
| expect(result.current).toHaveLength(2); | ||
| }); | ||
|
|
||
| it('should update when status filter changes', () => { | ||
| const tasks: Task[] = [ | ||
| createMockTask({ id: '1', status: 'todo' }), | ||
| createMockTask({ id: '2', status: 'inprogress' }), | ||
| ]; | ||
|
|
||
| const { result, rerender } = renderHook( | ||
| ({ tasks, status }) => useFilteredTasks(tasks, status), | ||
| { initialProps: { tasks, status: 'todo' as TaskStatus } } | ||
| ); | ||
|
|
||
| expect(result.current).toHaveLength(1); | ||
| expect(result.current[0].id).toBe('1'); | ||
|
|
||
| rerender({ tasks, status: 'inprogress' }); | ||
|
|
||
| expect(result.current).toHaveLength(1); | ||
| expect(result.current[0].id).toBe('2'); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Running the new Vitest suite will fail immediately because
useFilteredTasksis imported from../useFilteredTasks, but that hook is not present anywhere infrontend/src/hooks(rg only finds this test). Module resolution will throwCannot find module '../useFilteredTasks', sonpm test/vitestcannot start until the hook is implemented or the import is corrected.Useful? React with 👍 / 👎.