Spaces:
				
			
			
	
			
			
		Build error
		
	
	
	
			
			
	
	
	
	
		
		
		Build error
		
	Upload 565 files
Browse filesThis view is limited to 50 files because it contains too many changes.  
							See raw diff
- .gitattributes +1 -0
- frontend/README.md +254 -0
- frontend/__tests__/api/file-service/file-service.api.test.ts +29 -0
- frontend/__tests__/components/browser.test.tsx +86 -0
- frontend/__tests__/components/buttons/copy-to-clipboard.test.tsx +40 -0
- frontend/__tests__/components/chat-message.test.tsx +72 -0
- frontend/__tests__/components/chat/action-suggestions.test.tsx +132 -0
- frontend/__tests__/components/chat/chat-input.test.tsx +256 -0
- frontend/__tests__/components/chat/chat-interface.test.tsx +366 -0
- frontend/__tests__/components/chat/expandable-message.test.tsx +141 -0
- frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx +74 -0
- frontend/__tests__/components/context-menu/context-menu-list-item.test.tsx +44 -0
- frontend/__tests__/components/features/analytics/analytics-consent-form-modal.test.tsx +30 -0
- frontend/__tests__/components/features/auth-modal.test.tsx +47 -0
- frontend/__tests__/components/features/chat/path-component.test.tsx +34 -0
- frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx +489 -0
- frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx +290 -0
- frontend/__tests__/components/features/conversation-panel/utils.ts +17 -0
- frontend/__tests__/components/features/home/home-header.test.tsx +93 -0
- frontend/__tests__/components/features/home/repo-connector.test.tsx +241 -0
- frontend/__tests__/components/features/home/repo-selection-form.test.tsx +259 -0
- frontend/__tests__/components/features/home/task-card.test.tsx +108 -0
- frontend/__tests__/components/features/home/task-suggestions.test.tsx +96 -0
- frontend/__tests__/components/features/payment/payment-form.test.tsx +180 -0
- frontend/__tests__/components/features/settings/api-keys-manager.test.tsx +59 -0
- frontend/__tests__/components/features/sidebar/sidebar.test.tsx +32 -0
- frontend/__tests__/components/feedback-actions.test.tsx +76 -0
- frontend/__tests__/components/feedback-form.test.tsx +68 -0
- frontend/__tests__/components/file-operations.test.tsx +11 -0
- frontend/__tests__/components/image-preview.test.tsx +37 -0
- frontend/__tests__/components/interactive-chat-box.test.tsx +190 -0
- frontend/__tests__/components/jupyter/jupyter.test.tsx +45 -0
- frontend/__tests__/components/landing-translations.test.tsx +190 -0
- frontend/__tests__/components/modals/base-modal/base-modal.test.tsx +151 -0
- frontend/__tests__/components/modals/settings/model-selector.test.tsx +136 -0
- frontend/__tests__/components/settings/settings-input.test.tsx +109 -0
- frontend/__tests__/components/settings/settings-switch.test.tsx +64 -0
- frontend/__tests__/components/shared/brand-button.test.tsx +55 -0
- frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx +40 -0
- frontend/__tests__/components/suggestion-item.test.tsx +58 -0
- frontend/__tests__/components/suggestions.test.tsx +60 -0
- frontend/__tests__/components/terminal/terminal.test.tsx +132 -0
- frontend/__tests__/components/upload-image-input.test.tsx +71 -0
- frontend/__tests__/components/user-actions.test.tsx +71 -0
- frontend/__tests__/components/user-avatar.test.tsx +68 -0
- frontend/__tests__/context/ws-client-provider.test.tsx +98 -0
- frontend/__tests__/hooks/mutation/use-save-settings.test.tsx +36 -0
- frontend/__tests__/hooks/use-click-outside-element.test.tsx +36 -0
- frontend/__tests__/hooks/use-rate.test.ts +93 -0
- frontend/__tests__/hooks/use-terminal.test.tsx +111 -0
    	
        .gitattributes
    CHANGED
    
    | @@ -46,3 +46,4 @@ docs/usage/llms/screenshots/2_select_model.png filter=lfs diff=lfs merge=lfs -te | |
| 46 | 
             
            docs/usage/llms/screenshots/4_set_context_window.png filter=lfs diff=lfs merge=lfs -text
         | 
| 47 | 
             
            docs/usage/llms/screenshots/5_copy_url.png filter=lfs diff=lfs merge=lfs -text
         | 
| 48 | 
             
            evaluation/static/example_task_1.png filter=lfs diff=lfs merge=lfs -text
         | 
|  | 
|  | |
| 46 | 
             
            docs/usage/llms/screenshots/4_set_context_window.png filter=lfs diff=lfs merge=lfs -text
         | 
| 47 | 
             
            docs/usage/llms/screenshots/5_copy_url.png filter=lfs diff=lfs merge=lfs -text
         | 
| 48 | 
             
            evaluation/static/example_task_1.png filter=lfs diff=lfs merge=lfs -text
         | 
| 49 | 
            +
            frontend/src/assets/logo.png filter=lfs diff=lfs merge=lfs -text
         | 
    	
        frontend/README.md
    ADDED
    
    | @@ -0,0 +1,254 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            # Getting Started with the OpenHands Frontend
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            ## Overview
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            This is the frontend of the OpenHands project. It is a React application that provides a web interface for the OpenHands project.
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            ## Tech Stack
         | 
| 8 | 
            +
             | 
| 9 | 
            +
            - Remix SPA Mode (React + Vite + React Router)
         | 
| 10 | 
            +
            - TypeScript
         | 
| 11 | 
            +
            - Redux
         | 
| 12 | 
            +
            - TanStack Query
         | 
| 13 | 
            +
            - Tailwind CSS
         | 
| 14 | 
            +
            - i18next
         | 
| 15 | 
            +
            - React Testing Library
         | 
| 16 | 
            +
            - Vitest
         | 
| 17 | 
            +
            - Mock Service Worker
         | 
| 18 | 
            +
             | 
| 19 | 
            +
            ## Getting Started
         | 
| 20 | 
            +
             | 
| 21 | 
            +
            ### Prerequisites
         | 
| 22 | 
            +
             | 
| 23 | 
            +
            - Node.js 20.x or later
         | 
| 24 | 
            +
            - `npm`, `bun`, or any other package manager that supports the `package.json` file
         | 
| 25 | 
            +
             | 
| 26 | 
            +
            ### Installation
         | 
| 27 | 
            +
             | 
| 28 | 
            +
            ```sh
         | 
| 29 | 
            +
            # Clone the repository
         | 
| 30 | 
            +
            git clone https://github.com/All-Hands-AI/OpenHands.git
         | 
| 31 | 
            +
             | 
| 32 | 
            +
            # Change the directory to the frontend
         | 
| 33 | 
            +
            cd OpenHands/frontend
         | 
| 34 | 
            +
             | 
| 35 | 
            +
            # Install the dependencies
         | 
| 36 | 
            +
            npm install
         | 
| 37 | 
            +
            ```
         | 
| 38 | 
            +
             | 
| 39 | 
            +
            ### Running the Application in Development Mode
         | 
| 40 | 
            +
             | 
| 41 | 
            +
            We use `msw` to mock the backend API. To start the application with the mocked backend, run the following command:
         | 
| 42 | 
            +
             | 
| 43 | 
            +
            ```sh
         | 
| 44 | 
            +
            npm run dev
         | 
| 45 | 
            +
            ```
         | 
| 46 | 
            +
             | 
| 47 | 
            +
            This will start the application in development mode. Open [http://localhost:3001](http://localhost:3001) to view it in the browser.
         | 
| 48 | 
            +
             | 
| 49 | 
            +
            **NOTE: The backend is _partially_ mocked using `msw`. Therefore, some features may not work as they would with the actual backend.**
         | 
| 50 | 
            +
             | 
| 51 | 
            +
            See the [Development.md](../Development.md) for extra tips on how to run in development mode.
         | 
| 52 | 
            +
             | 
| 53 | 
            +
            ### Running the Application with the Actual Backend (Production Mode)
         | 
| 54 | 
            +
             | 
| 55 | 
            +
            To run the application with the actual backend:
         | 
| 56 | 
            +
             | 
| 57 | 
            +
            ```sh
         | 
| 58 | 
            +
            # Build the application from the root directory
         | 
| 59 | 
            +
            make build
         | 
| 60 | 
            +
             | 
| 61 | 
            +
            # Start the application
         | 
| 62 | 
            +
            make run
         | 
| 63 | 
            +
            ```
         | 
| 64 | 
            +
            Or to run backend and frontend separately.
         | 
| 65 | 
            +
             | 
| 66 | 
            +
            ```sh
         | 
| 67 | 
            +
            # Start the backend from the root directory
         | 
| 68 | 
            +
            make start-backend
         | 
| 69 | 
            +
             | 
| 70 | 
            +
            # Serve the frontend
         | 
| 71 | 
            +
            make start-frontend or
         | 
| 72 | 
            +
            cd frontend && npm start -- --port 3001
         | 
| 73 | 
            +
            ```
         | 
| 74 | 
            +
             | 
| 75 | 
            +
            Start frontend with Mock Service Worker (MSW), see testing for more info.
         | 
| 76 | 
            +
            ```sh
         | 
| 77 | 
            +
            npm run dev:mock or npm run dev:mock:saas
         | 
| 78 | 
            +
            ```
         | 
| 79 | 
            +
             | 
| 80 | 
            +
            ### Environment Variables
         | 
| 81 | 
            +
             | 
| 82 | 
            +
            The frontend application uses the following environment variables:
         | 
| 83 | 
            +
             | 
| 84 | 
            +
            | Variable                    | Description                                                            | Default Value    |
         | 
| 85 | 
            +
            | --------------------------- | ---------------------------------------------------------------------- | ---------------- |
         | 
| 86 | 
            +
            | `VITE_BACKEND_BASE_URL`     | The backend hostname without protocol (used for WebSocket connections) | `localhost:3000` |
         | 
| 87 | 
            +
            | `VITE_BACKEND_HOST`         | The backend host with port for API connections                         | `127.0.0.1:3000` |
         | 
| 88 | 
            +
            | `VITE_MOCK_API`             | Enable/disable API mocking with MSW                                    | `false`          |
         | 
| 89 | 
            +
            | `VITE_MOCK_SAAS`            | Simulate SaaS mode in development                                      | `false`          |
         | 
| 90 | 
            +
            | `VITE_USE_TLS`              | Use HTTPS/WSS for backend connections                                  | `false`          |
         | 
| 91 | 
            +
            | `VITE_FRONTEND_PORT`        | Port to run the frontend application                                   | `3001`           |
         | 
| 92 | 
            +
            | `VITE_INSECURE_SKIP_VERIFY` | Skip TLS certificate verification                                      | `false`          |
         | 
| 93 | 
            +
            | `VITE_GITHUB_TOKEN`         | GitHub token for repository access (used in some tests)                | -                |
         | 
| 94 | 
            +
             | 
| 95 | 
            +
            You can create a `.env` file in the frontend directory with these variables based on the `.env.sample` file.
         | 
| 96 | 
            +
             | 
| 97 | 
            +
            ### Project Structure
         | 
| 98 | 
            +
             | 
| 99 | 
            +
            ```sh
         | 
| 100 | 
            +
            frontend
         | 
| 101 | 
            +
            ├── __tests__ # Tests
         | 
| 102 | 
            +
            ├── public
         | 
| 103 | 
            +
            ├── src
         | 
| 104 | 
            +
            │   ├── api # API calls
         | 
| 105 | 
            +
            │   ├── assets
         | 
| 106 | 
            +
            │   ├── components
         | 
| 107 | 
            +
            │   ├── context # Local state management
         | 
| 108 | 
            +
            │   ├── hooks # Custom hooks
         | 
| 109 | 
            +
            │   ├── i18n # Internationalization
         | 
| 110 | 
            +
            │   ├── mocks # MSW mocks for development
         | 
| 111 | 
            +
            │   ├── routes # React Router file-based routes
         | 
| 112 | 
            +
            │   ├── services
         | 
| 113 | 
            +
            │   ├── state # Redux state management
         | 
| 114 | 
            +
            │   ├── types
         | 
| 115 | 
            +
            │   ├── utils # Utility/helper functions
         | 
| 116 | 
            +
            │   └── root.tsx # Entry point
         | 
| 117 | 
            +
            └── .env.sample # Sample environment variables
         | 
| 118 | 
            +
            ```
         | 
| 119 | 
            +
             | 
| 120 | 
            +
            #### Components
         | 
| 121 | 
            +
             | 
| 122 | 
            +
            Components are organized into folders based on their **domain**, **feature**, or **shared functionality**.
         | 
| 123 | 
            +
             | 
| 124 | 
            +
            ```sh
         | 
| 125 | 
            +
            components
         | 
| 126 | 
            +
            ├── features # Domain-specific components
         | 
| 127 | 
            +
            ├── layout
         | 
| 128 | 
            +
            ├── modals
         | 
| 129 | 
            +
            └── ui # Shared UI components
         | 
| 130 | 
            +
            ```
         | 
| 131 | 
            +
             | 
| 132 | 
            +
            ### Features
         | 
| 133 | 
            +
             | 
| 134 | 
            +
            - Real-time updates with WebSockets
         | 
| 135 | 
            +
            - Internationalization
         | 
| 136 | 
            +
            - Router data loading with Remix
         | 
| 137 | 
            +
            - User authentication with GitHub OAuth (if saas mode is enabled)
         | 
| 138 | 
            +
             | 
| 139 | 
            +
            ## Testing
         | 
| 140 | 
            +
             | 
| 141 | 
            +
            ### Testing Framework and Tools
         | 
| 142 | 
            +
             | 
| 143 | 
            +
            We use the following testing tools:
         | 
| 144 | 
            +
            - **Test Runner**: Vitest
         | 
| 145 | 
            +
            - **Rendering**: React Testing Library
         | 
| 146 | 
            +
            - **User Interactions**: @testing-library/user-event
         | 
| 147 | 
            +
            - **API Mocking**: [Mock Service Worker (MSW)](https://mswjs.io/)
         | 
| 148 | 
            +
            - **Code Coverage**: Vitest with V8 coverage
         | 
| 149 | 
            +
             | 
| 150 | 
            +
            ### Running Tests
         | 
| 151 | 
            +
             | 
| 152 | 
            +
            To run all tests:
         | 
| 153 | 
            +
            ```sh
         | 
| 154 | 
            +
            npm run test
         | 
| 155 | 
            +
            ```
         | 
| 156 | 
            +
             | 
| 157 | 
            +
            To run tests with coverage:
         | 
| 158 | 
            +
            ```sh
         | 
| 159 | 
            +
            npm run test:coverage
         | 
| 160 | 
            +
            ```
         | 
| 161 | 
            +
             | 
| 162 | 
            +
            ### Testing Best Practices
         | 
| 163 | 
            +
             | 
| 164 | 
            +
            1. **Component Testing**
         | 
| 165 | 
            +
               - Test components in isolation
         | 
| 166 | 
            +
               - Use our custom [`renderWithProviders()`](https://github.com/All-Hands-AI/OpenHands/blob/ce26f1c6d3feec3eedf36f823dee732b5a61e517/frontend/test-utils.tsx#L56-L85) that wraps the components we want to test in our providers. It is especially useful for components that use Redux
         | 
| 167 | 
            +
               - Use `render()` from React Testing Library to render components
         | 
| 168 | 
            +
               - Prefer querying elements by role, label, or test ID over CSS selectors
         | 
| 169 | 
            +
               - Test both rendering and interaction scenarios
         | 
| 170 | 
            +
             | 
| 171 | 
            +
            2. **User Event Simulation**
         | 
| 172 | 
            +
               - Use `userEvent` for simulating realistic user interactions
         | 
| 173 | 
            +
               - Test keyboard events, clicks, typing, and other user actions
         | 
| 174 | 
            +
               - Handle edge cases like disabled states, empty inputs, etc.
         | 
| 175 | 
            +
             | 
| 176 | 
            +
            3. **Mocking**
         | 
| 177 | 
            +
               - We test components that make network requests by mocking those requests with Mock Service Worker (MSW)
         | 
| 178 | 
            +
               - Use `vi.fn()` to create mock functions for callbacks and event handlers
         | 
| 179 | 
            +
               - Mock external dependencies and API calls (more info)[https://mswjs.io/docs/getting-started]
         | 
| 180 | 
            +
               - Verify mock function calls using `.toHaveBeenCalledWith()`, `.toHaveBeenCalledTimes()`
         | 
| 181 | 
            +
             | 
| 182 | 
            +
            4. **Accessibility Testing**
         | 
| 183 | 
            +
               - Use `toBeInTheDocument()` to check element presence
         | 
| 184 | 
            +
               - Test keyboard navigation and screen reader compatibility
         | 
| 185 | 
            +
               - Verify correct ARIA attributes and roles
         | 
| 186 | 
            +
             | 
| 187 | 
            +
            5. **State and Prop Testing**
         | 
| 188 | 
            +
               - Test component behavior with different prop combinations
         | 
| 189 | 
            +
               - Verify state changes and conditional rendering
         | 
| 190 | 
            +
               - Test error states and loading scenarios
         | 
| 191 | 
            +
             | 
| 192 | 
            +
            6. **Internationalization (i18n) Testing**
         | 
| 193 | 
            +
               - Test translation keys and placeholders
         | 
| 194 | 
            +
               - Verify text rendering across different languages
         | 
| 195 | 
            +
             | 
| 196 | 
            +
            Example Test Structure:
         | 
| 197 | 
            +
            ```typescript
         | 
| 198 | 
            +
            import { render, screen } from "@testing-library/react";
         | 
| 199 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 200 | 
            +
            import { describe, it, expect, vi } from "vitest";
         | 
| 201 | 
            +
             | 
| 202 | 
            +
            describe("ComponentName", () => {
         | 
| 203 | 
            +
              it("should render correctly", () => {
         | 
| 204 | 
            +
                render(<Component />);
         | 
| 205 | 
            +
                expect(screen.getByRole("button")).toBeInTheDocument();
         | 
| 206 | 
            +
              });
         | 
| 207 | 
            +
             | 
| 208 | 
            +
              it("should handle user interactions", async () => {
         | 
| 209 | 
            +
                const mockCallback = vi.fn();
         | 
| 210 | 
            +
                const user = userEvent.setup();
         | 
| 211 | 
            +
             | 
| 212 | 
            +
                render(<Component onClick={mockCallback} />);
         | 
| 213 | 
            +
                const button = screen.getByRole("button");
         | 
| 214 | 
            +
             | 
| 215 | 
            +
                await user.click(button);
         | 
| 216 | 
            +
                expect(mockCallback).toHaveBeenCalledOnce();
         | 
| 217 | 
            +
              });
         | 
| 218 | 
            +
            });
         | 
| 219 | 
            +
            ```
         | 
| 220 | 
            +
             | 
| 221 | 
            +
            ### Example Tests in the Codebase
         | 
| 222 | 
            +
             | 
| 223 | 
            +
            For real-world examples of testing, check out these test files:
         | 
| 224 | 
            +
             | 
| 225 | 
            +
            1. **Chat Input Component Test**:
         | 
| 226 | 
            +
               [`__tests__/components/chat/chat-input.test.tsx`](https://github.com/All-Hands-AI/OpenHands/blob/main/frontend/__tests__/components/chat/chat-input.test.tsx)
         | 
| 227 | 
            +
               - Demonstrates comprehensive testing of a complex input component
         | 
| 228 | 
            +
               - Covers various scenarios like submission, disabled states, and user interactions
         | 
| 229 | 
            +
             | 
| 230 | 
            +
            2. **File Explorer Component Test**:
         | 
| 231 | 
            +
               [`__tests__/components/file-explorer/file-explorer.test.tsx`](https://github.com/All-Hands-AI/OpenHands/blob/main/frontend/__tests__/components/file-explorer/file-explorer.test.tsx)
         | 
| 232 | 
            +
               - Shows testing of a more complex component with multiple interactions
         | 
| 233 | 
            +
               - Illustrates testing of nested components and state management
         | 
| 234 | 
            +
             | 
| 235 | 
            +
            ### Test Coverage
         | 
| 236 | 
            +
             | 
| 237 | 
            +
            - Aim for high test coverage, especially for critical components
         | 
| 238 | 
            +
            - Focus on testing different scenarios and edge cases
         | 
| 239 | 
            +
            - Use code coverage reports to identify untested code paths
         | 
| 240 | 
            +
             | 
| 241 | 
            +
            ### Continuous Integration
         | 
| 242 | 
            +
             | 
| 243 | 
            +
            Tests are automatically run during:
         | 
| 244 | 
            +
            - Pre-commit hooks
         | 
| 245 | 
            +
            - Pull request checks
         | 
| 246 | 
            +
            - CI/CD pipeline
         | 
| 247 | 
            +
             | 
| 248 | 
            +
            ## Contributing
         | 
| 249 | 
            +
             | 
| 250 | 
            +
            Please read the [CONTRIBUTING.md](../CONTRIBUTING.md) file for details on our code of conduct, and the process for submitting pull requests to us.
         | 
| 251 | 
            +
             | 
| 252 | 
            +
            ## Troubleshooting
         | 
| 253 | 
            +
             | 
| 254 | 
            +
            TODO
         | 
    	
        frontend/__tests__/api/file-service/file-service.api.test.ts
    ADDED
    
    | @@ -0,0 +1,29 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { describe, expect, it } from "vitest";
         | 
| 2 | 
            +
            import { FileService } from "#/api/file-service/file-service.api";
         | 
| 3 | 
            +
            import {
         | 
| 4 | 
            +
              FILE_VARIANTS_1,
         | 
| 5 | 
            +
              FILE_VARIANTS_2,
         | 
| 6 | 
            +
            } from "#/mocks/file-service-handlers";
         | 
| 7 | 
            +
             | 
| 8 | 
            +
            /**
         | 
| 9 | 
            +
             * File service API tests. The actual API calls are mocked using MSW.
         | 
| 10 | 
            +
             * You can find the mock handlers in `frontend/src/mocks/file-service-handlers.ts`.
         | 
| 11 | 
            +
             */
         | 
| 12 | 
            +
             | 
| 13 | 
            +
            describe("FileService", () => {
         | 
| 14 | 
            +
              it("should get a list of files", async () => {
         | 
| 15 | 
            +
                await expect(FileService.getFiles("test-conversation-id")).resolves.toEqual(
         | 
| 16 | 
            +
                  FILE_VARIANTS_1,
         | 
| 17 | 
            +
                );
         | 
| 18 | 
            +
             | 
| 19 | 
            +
                await expect(
         | 
| 20 | 
            +
                  FileService.getFiles("test-conversation-id-2"),
         | 
| 21 | 
            +
                ).resolves.toEqual(FILE_VARIANTS_2);
         | 
| 22 | 
            +
              });
         | 
| 23 | 
            +
             | 
| 24 | 
            +
              it("should get content of a file", async () => {
         | 
| 25 | 
            +
                await expect(
         | 
| 26 | 
            +
                  FileService.getFile("test-conversation-id", "file1.txt"),
         | 
| 27 | 
            +
                ).resolves.toEqual("Content of file1.txt");
         | 
| 28 | 
            +
              });
         | 
| 29 | 
            +
            });
         | 
    	
        frontend/__tests__/components/browser.test.tsx
    ADDED
    
    | @@ -0,0 +1,86 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { describe, it, expect, afterEach, vi } from "vitest";
         | 
| 2 | 
            +
            import { screen, render } from "@testing-library/react";
         | 
| 3 | 
            +
            import React from "react";
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            // Mock modules before importing the component
         | 
| 6 | 
            +
            vi.mock("react-router", async () => {
         | 
| 7 | 
            +
              const actual = await vi.importActual("react-router");
         | 
| 8 | 
            +
              return {
         | 
| 9 | 
            +
                ...(actual as object),
         | 
| 10 | 
            +
                useParams: () => ({ conversationId: "test-conversation-id" }),
         | 
| 11 | 
            +
              };
         | 
| 12 | 
            +
            });
         | 
| 13 | 
            +
             | 
| 14 | 
            +
            vi.mock("#/context/conversation-context", () => ({
         | 
| 15 | 
            +
              useConversation: () => ({ conversationId: "test-conversation-id" }),
         | 
| 16 | 
            +
              ConversationProvider: ({ children }: { children: React.ReactNode }) => children,
         | 
| 17 | 
            +
            }));
         | 
| 18 | 
            +
             | 
| 19 | 
            +
            vi.mock("react-i18next", async () => {
         | 
| 20 | 
            +
              const actual = await vi.importActual("react-i18next");
         | 
| 21 | 
            +
              return {
         | 
| 22 | 
            +
                ...(actual as object),
         | 
| 23 | 
            +
                useTranslation: () => ({
         | 
| 24 | 
            +
                  t: (key: string) => key,
         | 
| 25 | 
            +
                  i18n: {
         | 
| 26 | 
            +
                    changeLanguage: () => new Promise(() => {}),
         | 
| 27 | 
            +
                  },
         | 
| 28 | 
            +
                }),
         | 
| 29 | 
            +
              };
         | 
| 30 | 
            +
            });
         | 
| 31 | 
            +
             | 
| 32 | 
            +
            // Mock redux
         | 
| 33 | 
            +
            const mockDispatch = vi.fn();
         | 
| 34 | 
            +
            let mockBrowserState = {
         | 
| 35 | 
            +
              url: "https://example.com",
         | 
| 36 | 
            +
              screenshotSrc: "",
         | 
| 37 | 
            +
            };
         | 
| 38 | 
            +
             | 
| 39 | 
            +
            vi.mock("react-redux", async () => {
         | 
| 40 | 
            +
              const actual = await vi.importActual("react-redux");
         | 
| 41 | 
            +
              return {
         | 
| 42 | 
            +
                ...actual,
         | 
| 43 | 
            +
                useDispatch: () => mockDispatch,
         | 
| 44 | 
            +
                useSelector: () => mockBrowserState,
         | 
| 45 | 
            +
              };
         | 
| 46 | 
            +
            });
         | 
| 47 | 
            +
             | 
| 48 | 
            +
            // Import the component after all mocks are set up
         | 
| 49 | 
            +
            import { BrowserPanel } from "#/components/features/browser/browser";
         | 
| 50 | 
            +
             | 
| 51 | 
            +
            describe("Browser", () => {
         | 
| 52 | 
            +
              afterEach(() => {
         | 
| 53 | 
            +
                vi.clearAllMocks();
         | 
| 54 | 
            +
                // Reset the mock state
         | 
| 55 | 
            +
                mockBrowserState = {
         | 
| 56 | 
            +
                  url: "https://example.com",
         | 
| 57 | 
            +
                  screenshotSrc: "",
         | 
| 58 | 
            +
                };
         | 
| 59 | 
            +
              });
         | 
| 60 | 
            +
             | 
| 61 | 
            +
              it("renders a message if no screenshotSrc is provided", () => {
         | 
| 62 | 
            +
                // Set the mock state for this test
         | 
| 63 | 
            +
                mockBrowserState = {
         | 
| 64 | 
            +
                  url: "https://example.com",
         | 
| 65 | 
            +
                  screenshotSrc: "",
         | 
| 66 | 
            +
                };
         | 
| 67 | 
            +
             | 
| 68 | 
            +
                render(<BrowserPanel />);
         | 
| 69 | 
            +
             | 
| 70 | 
            +
                // i18n empty message key
         | 
| 71 | 
            +
                expect(screen.getByText("BROWSER$NO_PAGE_LOADED")).toBeInTheDocument();
         | 
| 72 | 
            +
              });
         | 
| 73 | 
            +
             | 
| 74 | 
            +
              it("renders the url and a screenshot", () => {
         | 
| 75 | 
            +
                // Set the mock state for this test
         | 
| 76 | 
            +
                mockBrowserState = {
         | 
| 77 | 
            +
                  url: "https://example.com",
         | 
| 78 | 
            +
                  screenshotSrc: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mN0uGvyHwAFCAJS091fQwAAAABJRU5ErkJggg==",
         | 
| 79 | 
            +
                };
         | 
| 80 | 
            +
             | 
| 81 | 
            +
                render(<BrowserPanel />);
         | 
| 82 | 
            +
             | 
| 83 | 
            +
                expect(screen.getByText("https://example.com")).toBeInTheDocument();
         | 
| 84 | 
            +
                expect(screen.getByAltText("BROWSER$SCREENSHOT_ALT")).toBeInTheDocument();
         | 
| 85 | 
            +
              });
         | 
| 86 | 
            +
            });
         | 
    	
        frontend/__tests__/components/buttons/copy-to-clipboard.test.tsx
    ADDED
    
    | @@ -0,0 +1,40 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { render, screen } from "@testing-library/react";
         | 
| 2 | 
            +
            import { test, expect, describe, vi } from "vitest";
         | 
| 3 | 
            +
            import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipboard-button";
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            // Mock react-i18next
         | 
| 6 | 
            +
            vi.mock("react-i18next", () => ({
         | 
| 7 | 
            +
              useTranslation: () => ({
         | 
| 8 | 
            +
                t: (key: string) => key,
         | 
| 9 | 
            +
              }),
         | 
| 10 | 
            +
            }));
         | 
| 11 | 
            +
             | 
| 12 | 
            +
            describe("CopyToClipboardButton", () => {
         | 
| 13 | 
            +
              test("should have localized aria-label", () => {
         | 
| 14 | 
            +
                render(
         | 
| 15 | 
            +
                  <CopyToClipboardButton
         | 
| 16 | 
            +
                    isHidden={false}
         | 
| 17 | 
            +
                    isDisabled={false}
         | 
| 18 | 
            +
                    onClick={() => {}}
         | 
| 19 | 
            +
                    mode="copy"
         | 
| 20 | 
            +
                  />
         | 
| 21 | 
            +
                );
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                const button = screen.getByTestId("copy-to-clipboard");
         | 
| 24 | 
            +
                expect(button).toHaveAttribute("aria-label", "BUTTON$COPY");
         | 
| 25 | 
            +
              });
         | 
| 26 | 
            +
             | 
| 27 | 
            +
              test("should have localized aria-label when copied", () => {
         | 
| 28 | 
            +
                render(
         | 
| 29 | 
            +
                  <CopyToClipboardButton
         | 
| 30 | 
            +
                    isHidden={false}
         | 
| 31 | 
            +
                    isDisabled={false}
         | 
| 32 | 
            +
                    onClick={() => {}}
         | 
| 33 | 
            +
                    mode="copied"
         | 
| 34 | 
            +
                  />
         | 
| 35 | 
            +
                );
         | 
| 36 | 
            +
             | 
| 37 | 
            +
                const button = screen.getByTestId("copy-to-clipboard");
         | 
| 38 | 
            +
                expect(button).toHaveAttribute("aria-label", "BUTTON$COPIED");
         | 
| 39 | 
            +
              });
         | 
| 40 | 
            +
            });
         | 
    	
        frontend/__tests__/components/chat-message.test.tsx
    ADDED
    
    | @@ -0,0 +1,72 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { render, screen, waitFor } from "@testing-library/react";
         | 
| 2 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 3 | 
            +
            import { describe, it, expect } from "vitest";
         | 
| 4 | 
            +
            import { ChatMessage } from "#/components/features/chat/chat-message";
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            describe("ChatMessage", () => {
         | 
| 7 | 
            +
              it("should render a user message", () => {
         | 
| 8 | 
            +
                render(<ChatMessage type="user" message="Hello, World!" />);
         | 
| 9 | 
            +
                expect(screen.getByTestId("user-message")).toBeInTheDocument();
         | 
| 10 | 
            +
                expect(screen.getByText("Hello, World!")).toBeInTheDocument();
         | 
| 11 | 
            +
              });
         | 
| 12 | 
            +
             | 
| 13 | 
            +
              it.todo("should render an assistant message");
         | 
| 14 | 
            +
             | 
| 15 | 
            +
              it.skip("should support code syntax highlighting", () => {
         | 
| 16 | 
            +
                const code = "```js\nconsole.log('Hello, World!')\n```";
         | 
| 17 | 
            +
                render(<ChatMessage type="user" message={code} />);
         | 
| 18 | 
            +
             | 
| 19 | 
            +
                // SyntaxHighlighter breaks the code blocks into "tokens"
         | 
| 20 | 
            +
                expect(screen.getByText("console")).toBeInTheDocument();
         | 
| 21 | 
            +
                expect(screen.getByText("log")).toBeInTheDocument();
         | 
| 22 | 
            +
                expect(screen.getByText("'Hello, World!'")).toBeInTheDocument();
         | 
| 23 | 
            +
              });
         | 
| 24 | 
            +
             | 
| 25 | 
            +
              it("should render the copy to clipboard button when the user hovers over the message", async () => {
         | 
| 26 | 
            +
                const user = userEvent.setup();
         | 
| 27 | 
            +
                render(<ChatMessage type="user" message="Hello, World!" />);
         | 
| 28 | 
            +
                const message = screen.getByText("Hello, World!");
         | 
| 29 | 
            +
             | 
| 30 | 
            +
                expect(screen.getByTestId("copy-to-clipboard")).not.toBeVisible();
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                await user.hover(message);
         | 
| 33 | 
            +
             | 
| 34 | 
            +
                expect(screen.getByTestId("copy-to-clipboard")).toBeVisible();
         | 
| 35 | 
            +
              });
         | 
| 36 | 
            +
             | 
| 37 | 
            +
              it("should copy content to clipboard", async () => {
         | 
| 38 | 
            +
                const user = userEvent.setup();
         | 
| 39 | 
            +
                render(<ChatMessage type="user" message="Hello, World!" />);
         | 
| 40 | 
            +
                const copyToClipboardButton = screen.getByTestId("copy-to-clipboard");
         | 
| 41 | 
            +
             | 
| 42 | 
            +
                await user.click(copyToClipboardButton);
         | 
| 43 | 
            +
             | 
| 44 | 
            +
                await waitFor(() =>
         | 
| 45 | 
            +
                  expect(navigator.clipboard.readText()).resolves.toBe("Hello, World!"),
         | 
| 46 | 
            +
                );
         | 
| 47 | 
            +
              });
         | 
| 48 | 
            +
             | 
| 49 | 
            +
              it("should display an error toast if copying content to clipboard fails", async () => {});
         | 
| 50 | 
            +
             | 
| 51 | 
            +
              it("should render a component passed as a prop", () => {
         | 
| 52 | 
            +
                function Component() {
         | 
| 53 | 
            +
                  return <div data-testid="custom-component">Custom Component</div>;
         | 
| 54 | 
            +
                }
         | 
| 55 | 
            +
                render(
         | 
| 56 | 
            +
                  <ChatMessage type="user" message="Hello, World">
         | 
| 57 | 
            +
                    <Component />
         | 
| 58 | 
            +
                  </ChatMessage>,
         | 
| 59 | 
            +
                );
         | 
| 60 | 
            +
                expect(screen.getByTestId("custom-component")).toBeInTheDocument();
         | 
| 61 | 
            +
              });
         | 
| 62 | 
            +
             | 
| 63 | 
            +
              it("should apply correct styles to inline code", () => {
         | 
| 64 | 
            +
                render(
         | 
| 65 | 
            +
                  <ChatMessage type="agent" message="Here is some `inline code` text" />,
         | 
| 66 | 
            +
                );
         | 
| 67 | 
            +
                const codeElement = screen.getByText("inline code");
         | 
| 68 | 
            +
             | 
| 69 | 
            +
                expect(codeElement.tagName.toLowerCase()).toBe("code");
         | 
| 70 | 
            +
                expect(codeElement.closest("article")).not.toBeNull();
         | 
| 71 | 
            +
              });
         | 
| 72 | 
            +
            });
         | 
    	
        frontend/__tests__/components/chat/action-suggestions.test.tsx
    ADDED
    
    | @@ -0,0 +1,132 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { describe, expect, it, vi, beforeEach } from "vitest";
         | 
| 2 | 
            +
            import { render, screen } from "@testing-library/react";
         | 
| 3 | 
            +
            import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
         | 
| 4 | 
            +
            import { ActionSuggestions } from "#/components/features/chat/action-suggestions";
         | 
| 5 | 
            +
            import OpenHands from "#/api/open-hands";
         | 
| 6 | 
            +
            import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
         | 
| 7 | 
            +
             | 
| 8 | 
            +
            // Mock dependencies
         | 
| 9 | 
            +
            vi.mock("posthog-js", () => ({
         | 
| 10 | 
            +
              default: {
         | 
| 11 | 
            +
                capture: vi.fn(),
         | 
| 12 | 
            +
              },
         | 
| 13 | 
            +
            }));
         | 
| 14 | 
            +
             | 
| 15 | 
            +
            const { useSelectorMock } = vi.hoisted(() => ({
         | 
| 16 | 
            +
              useSelectorMock: vi.fn(),
         | 
| 17 | 
            +
            }));
         | 
| 18 | 
            +
             | 
| 19 | 
            +
            vi.mock("react-redux", () => ({
         | 
| 20 | 
            +
              useSelector: useSelectorMock,
         | 
| 21 | 
            +
            }));
         | 
| 22 | 
            +
             | 
| 23 | 
            +
            vi.mock("#/context/auth-context", () => ({
         | 
| 24 | 
            +
              useAuth: vi.fn(),
         | 
| 25 | 
            +
            }));
         | 
| 26 | 
            +
             | 
| 27 | 
            +
            // Mock react-i18next
         | 
| 28 | 
            +
            vi.mock("react-i18next", () => ({
         | 
| 29 | 
            +
              useTranslation: () => ({
         | 
| 30 | 
            +
                t: (key: string) => {
         | 
| 31 | 
            +
                  const translations: Record<string, string> = {
         | 
| 32 | 
            +
                    ACTION$PUSH_TO_BRANCH: "Push to Branch",
         | 
| 33 | 
            +
                    ACTION$PUSH_CREATE_PR: "Push & Create PR",
         | 
| 34 | 
            +
                    ACTION$PUSH_CHANGES_TO_PR: "Push Changes to PR",
         | 
| 35 | 
            +
                  };
         | 
| 36 | 
            +
                  return translations[key] || key;
         | 
| 37 | 
            +
                },
         | 
| 38 | 
            +
              }),
         | 
| 39 | 
            +
            }));
         | 
| 40 | 
            +
             | 
| 41 | 
            +
            vi.mock("react-router", () => ({
         | 
| 42 | 
            +
              useParams: () => ({
         | 
| 43 | 
            +
                conversationId: "test-conversation-id",
         | 
| 44 | 
            +
              }),
         | 
| 45 | 
            +
            }));
         | 
| 46 | 
            +
             | 
| 47 | 
            +
            const renderActionSuggestions = () =>
         | 
| 48 | 
            +
              render(<ActionSuggestions onSuggestionsClick={() => {}} />, {
         | 
| 49 | 
            +
                wrapper: ({ children }) => (
         | 
| 50 | 
            +
                  <QueryClientProvider client={new QueryClient()}>
         | 
| 51 | 
            +
                    {children}
         | 
| 52 | 
            +
                  </QueryClientProvider>
         | 
| 53 | 
            +
                ),
         | 
| 54 | 
            +
              });
         | 
| 55 | 
            +
             | 
| 56 | 
            +
            describe("ActionSuggestions", () => {
         | 
| 57 | 
            +
              // Setup mocks for each test
         | 
| 58 | 
            +
              beforeEach(() => {
         | 
| 59 | 
            +
                vi.clearAllMocks();
         | 
| 60 | 
            +
                const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
         | 
| 61 | 
            +
                getSettingsSpy.mockResolvedValue({
         | 
| 62 | 
            +
                  ...MOCK_DEFAULT_USER_SETTINGS,
         | 
| 63 | 
            +
                  provider_tokens_set: {
         | 
| 64 | 
            +
                    github: "some-token",
         | 
| 65 | 
            +
                  },
         | 
| 66 | 
            +
                });
         | 
| 67 | 
            +
             | 
| 68 | 
            +
                useSelectorMock.mockReturnValue({
         | 
| 69 | 
            +
                  selectedRepository: "test-repo",
         | 
| 70 | 
            +
                });
         | 
| 71 | 
            +
              });
         | 
| 72 | 
            +
             | 
| 73 | 
            +
              it("should render both GitHub buttons when GitHub token is set and repository is selected", async () => {
         | 
| 74 | 
            +
                const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
         | 
| 75 | 
            +
                // @ts-expect-error - only required for testing
         | 
| 76 | 
            +
                getConversationSpy.mockResolvedValue({
         | 
| 77 | 
            +
                  selected_repository: "test-repo",
         | 
| 78 | 
            +
                });
         | 
| 79 | 
            +
                renderActionSuggestions();
         | 
| 80 | 
            +
             | 
| 81 | 
            +
                // Find all buttons with data-testid="suggestion"
         | 
| 82 | 
            +
                const buttons = await screen.findAllByTestId("suggestion");
         | 
| 83 | 
            +
             | 
| 84 | 
            +
                // Check if we have at least 2 buttons
         | 
| 85 | 
            +
                expect(buttons.length).toBeGreaterThanOrEqual(2);
         | 
| 86 | 
            +
             | 
| 87 | 
            +
                // Check if the buttons contain the expected text
         | 
| 88 | 
            +
                const pushButton = buttons.find((button) =>
         | 
| 89 | 
            +
                  button.textContent?.includes("Push to Branch"),
         | 
| 90 | 
            +
                );
         | 
| 91 | 
            +
                const prButton = buttons.find((button) =>
         | 
| 92 | 
            +
                  button.textContent?.includes("Push & Create PR"),
         | 
| 93 | 
            +
                );
         | 
| 94 | 
            +
             | 
| 95 | 
            +
                expect(pushButton).toBeInTheDocument();
         | 
| 96 | 
            +
                expect(prButton).toBeInTheDocument();
         | 
| 97 | 
            +
              });
         | 
| 98 | 
            +
             | 
| 99 | 
            +
              it("should not render buttons when GitHub token is not set", () => {
         | 
| 100 | 
            +
                renderActionSuggestions();
         | 
| 101 | 
            +
             | 
| 102 | 
            +
                expect(screen.queryByTestId("suggestion")).not.toBeInTheDocument();
         | 
| 103 | 
            +
              });
         | 
| 104 | 
            +
             | 
| 105 | 
            +
              it("should not render buttons when no repository is selected", () => {
         | 
| 106 | 
            +
                useSelectorMock.mockReturnValue({
         | 
| 107 | 
            +
                  selectedRepository: null,
         | 
| 108 | 
            +
                });
         | 
| 109 | 
            +
             | 
| 110 | 
            +
                renderActionSuggestions();
         | 
| 111 | 
            +
             | 
| 112 | 
            +
                expect(screen.queryByTestId("suggestion")).not.toBeInTheDocument();
         | 
| 113 | 
            +
              });
         | 
| 114 | 
            +
             | 
| 115 | 
            +
              it("should have different prompts for 'Push to Branch' and 'Push & Create PR' buttons", () => {
         | 
| 116 | 
            +
                // This test verifies that the prompts are different in the component
         | 
| 117 | 
            +
                renderActionSuggestions();
         | 
| 118 | 
            +
             | 
| 119 | 
            +
                // Get the component instance to access the internal values
         | 
| 120 | 
            +
                const pushBranchPrompt =
         | 
| 121 | 
            +
                  "Please push the changes to a remote branch on GitHub, but do NOT create a pull request. Please use the exact SAME branch name as the one you are currently on.";
         | 
| 122 | 
            +
                const createPRPrompt =
         | 
| 123 | 
            +
                  "Please push the changes to GitHub and open a pull request. Please create a meaningful branch name that describes the changes. If a pull request template exists in the repository, please follow it when creating the PR description.";
         | 
| 124 | 
            +
             | 
| 125 | 
            +
                // Verify the prompts are different
         | 
| 126 | 
            +
                expect(pushBranchPrompt).not.toEqual(createPRPrompt);
         | 
| 127 | 
            +
             | 
| 128 | 
            +
                // Verify the PR prompt mentions creating a meaningful branch name
         | 
| 129 | 
            +
                expect(createPRPrompt).toContain("meaningful branch name");
         | 
| 130 | 
            +
                expect(createPRPrompt).not.toContain("SAME branch name");
         | 
| 131 | 
            +
              });
         | 
| 132 | 
            +
            });
         | 
    	
        frontend/__tests__/components/chat/chat-input.test.tsx
    ADDED
    
    | @@ -0,0 +1,256 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 2 | 
            +
            import { fireEvent, render, screen } from "@testing-library/react";
         | 
| 3 | 
            +
            import { describe, afterEach, vi, it, expect } from "vitest";
         | 
| 4 | 
            +
            import { ChatInput } from "#/components/features/chat/chat-input";
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            describe("ChatInput", () => {
         | 
| 7 | 
            +
              const onSubmitMock = vi.fn();
         | 
| 8 | 
            +
             | 
| 9 | 
            +
              afterEach(() => {
         | 
| 10 | 
            +
                vi.clearAllMocks();
         | 
| 11 | 
            +
              });
         | 
| 12 | 
            +
             | 
| 13 | 
            +
              it("should render a textarea", () => {
         | 
| 14 | 
            +
                render(<ChatInput onSubmit={onSubmitMock} />);
         | 
| 15 | 
            +
                expect(screen.getByTestId("chat-input")).toBeInTheDocument();
         | 
| 16 | 
            +
                expect(screen.getByRole("textbox")).toBeInTheDocument();
         | 
| 17 | 
            +
              });
         | 
| 18 | 
            +
             | 
| 19 | 
            +
              it("should call onSubmit when the user types and presses enter", async () => {
         | 
| 20 | 
            +
                const user = userEvent.setup();
         | 
| 21 | 
            +
                render(<ChatInput onSubmit={onSubmitMock} />);
         | 
| 22 | 
            +
                const textarea = screen.getByRole("textbox");
         | 
| 23 | 
            +
             | 
| 24 | 
            +
                await user.type(textarea, "Hello, world!");
         | 
| 25 | 
            +
                await user.keyboard("{Enter}");
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!");
         | 
| 28 | 
            +
              });
         | 
| 29 | 
            +
             | 
| 30 | 
            +
              it("should call onSubmit when pressing the submit button", async () => {
         | 
| 31 | 
            +
                const user = userEvent.setup();
         | 
| 32 | 
            +
                render(<ChatInput onSubmit={onSubmitMock} />);
         | 
| 33 | 
            +
                const textarea = screen.getByRole("textbox");
         | 
| 34 | 
            +
                const button = screen.getByRole("button");
         | 
| 35 | 
            +
             | 
| 36 | 
            +
                await user.type(textarea, "Hello, world!");
         | 
| 37 | 
            +
                await user.click(button);
         | 
| 38 | 
            +
             | 
| 39 | 
            +
                expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!");
         | 
| 40 | 
            +
              });
         | 
| 41 | 
            +
             | 
| 42 | 
            +
              it("should not call onSubmit when the message is empty", async () => {
         | 
| 43 | 
            +
                const user = userEvent.setup();
         | 
| 44 | 
            +
                render(<ChatInput onSubmit={onSubmitMock} />);
         | 
| 45 | 
            +
                const button = screen.getByRole("button");
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                await user.click(button);
         | 
| 48 | 
            +
                expect(onSubmitMock).not.toHaveBeenCalled();
         | 
| 49 | 
            +
             | 
| 50 | 
            +
                await user.keyboard("{Enter}");
         | 
| 51 | 
            +
                expect(onSubmitMock).not.toHaveBeenCalled();
         | 
| 52 | 
            +
              });
         | 
| 53 | 
            +
             | 
| 54 | 
            +
              it("should not call onSubmit when the message is only whitespace", async () => {
         | 
| 55 | 
            +
                const user = userEvent.setup();
         | 
| 56 | 
            +
                render(<ChatInput onSubmit={onSubmitMock} />);
         | 
| 57 | 
            +
                const textarea = screen.getByRole("textbox");
         | 
| 58 | 
            +
             | 
| 59 | 
            +
                await user.type(textarea, "   ");
         | 
| 60 | 
            +
                await user.keyboard("{Enter}");
         | 
| 61 | 
            +
             | 
| 62 | 
            +
                expect(onSubmitMock).not.toHaveBeenCalled();
         | 
| 63 | 
            +
             | 
| 64 | 
            +
                await user.type(textarea, " \t\n");
         | 
| 65 | 
            +
                await user.keyboard("{Enter}");
         | 
| 66 | 
            +
             | 
| 67 | 
            +
                expect(onSubmitMock).not.toHaveBeenCalled();
         | 
| 68 | 
            +
              });
         | 
| 69 | 
            +
             | 
| 70 | 
            +
              it("should disable submit", async () => {
         | 
| 71 | 
            +
                const user = userEvent.setup();
         | 
| 72 | 
            +
                render(<ChatInput disabled onSubmit={onSubmitMock} />);
         | 
| 73 | 
            +
             | 
| 74 | 
            +
                const button = screen.getByRole("button");
         | 
| 75 | 
            +
                const textarea = screen.getByRole("textbox");
         | 
| 76 | 
            +
             | 
| 77 | 
            +
                await user.type(textarea, "Hello, world!");
         | 
| 78 | 
            +
             | 
| 79 | 
            +
                expect(button).toBeDisabled();
         | 
| 80 | 
            +
                await user.click(button);
         | 
| 81 | 
            +
                expect(onSubmitMock).not.toHaveBeenCalled();
         | 
| 82 | 
            +
             | 
| 83 | 
            +
                await user.keyboard("{Enter}");
         | 
| 84 | 
            +
                expect(onSubmitMock).not.toHaveBeenCalled();
         | 
| 85 | 
            +
              });
         | 
| 86 | 
            +
             | 
| 87 | 
            +
              it("should render a placeholder with translation key", () => {
         | 
| 88 | 
            +
                render(<ChatInput onSubmit={onSubmitMock} />);
         | 
| 89 | 
            +
             | 
| 90 | 
            +
                const textarea = screen.getByPlaceholderText("SUGGESTIONS$WHAT_TO_BUILD");
         | 
| 91 | 
            +
                expect(textarea).toBeInTheDocument();
         | 
| 92 | 
            +
              });
         | 
| 93 | 
            +
             | 
| 94 | 
            +
              it("should create a newline instead of submitting when shift + enter is pressed", async () => {
         | 
| 95 | 
            +
                const user = userEvent.setup();
         | 
| 96 | 
            +
                render(<ChatInput onSubmit={onSubmitMock} />);
         | 
| 97 | 
            +
                const textarea = screen.getByRole("textbox");
         | 
| 98 | 
            +
             | 
| 99 | 
            +
                await user.type(textarea, "Hello, world!");
         | 
| 100 | 
            +
                await user.keyboard("{Shift>} {Enter}"); // Shift + Enter
         | 
| 101 | 
            +
             | 
| 102 | 
            +
                expect(onSubmitMock).not.toHaveBeenCalled();
         | 
| 103 | 
            +
                // expect(textarea).toHaveValue("Hello, world!\n");
         | 
| 104 | 
            +
              });
         | 
| 105 | 
            +
             | 
| 106 | 
            +
              it("should clear the input message after sending a message", async () => {
         | 
| 107 | 
            +
                const user = userEvent.setup();
         | 
| 108 | 
            +
                render(<ChatInput onSubmit={onSubmitMock} />);
         | 
| 109 | 
            +
                const textarea = screen.getByRole("textbox");
         | 
| 110 | 
            +
                const button = screen.getByRole("button");
         | 
| 111 | 
            +
             | 
| 112 | 
            +
                await user.type(textarea, "Hello, world!");
         | 
| 113 | 
            +
                await user.keyboard("{Enter}");
         | 
| 114 | 
            +
                expect(textarea).toHaveValue("");
         | 
| 115 | 
            +
             | 
| 116 | 
            +
                await user.type(textarea, "Hello, world!");
         | 
| 117 | 
            +
                await user.click(button);
         | 
| 118 | 
            +
                expect(textarea).toHaveValue("");
         | 
| 119 | 
            +
              });
         | 
| 120 | 
            +
             | 
| 121 | 
            +
              it("should hide the submit button", () => {
         | 
| 122 | 
            +
                render(<ChatInput onSubmit={onSubmitMock} showButton={false} />);
         | 
| 123 | 
            +
                expect(screen.queryByRole("button")).not.toBeInTheDocument();
         | 
| 124 | 
            +
              });
         | 
| 125 | 
            +
             | 
| 126 | 
            +
              it("should call onChange when the user types", async () => {
         | 
| 127 | 
            +
                const user = userEvent.setup();
         | 
| 128 | 
            +
                const onChangeMock = vi.fn();
         | 
| 129 | 
            +
                render(<ChatInput onSubmit={onSubmitMock} onChange={onChangeMock} />);
         | 
| 130 | 
            +
                const textarea = screen.getByRole("textbox");
         | 
| 131 | 
            +
             | 
| 132 | 
            +
                await user.type(textarea, "Hello, world!");
         | 
| 133 | 
            +
             | 
| 134 | 
            +
                expect(onChangeMock).toHaveBeenCalledTimes("Hello, world!".length);
         | 
| 135 | 
            +
              });
         | 
| 136 | 
            +
             | 
| 137 | 
            +
              it("should have set the passed value", () => {
         | 
| 138 | 
            +
                render(<ChatInput value="Hello, world!" onSubmit={onSubmitMock} />);
         | 
| 139 | 
            +
                const textarea = screen.getByRole("textbox");
         | 
| 140 | 
            +
             | 
| 141 | 
            +
                expect(textarea).toHaveValue("Hello, world!");
         | 
| 142 | 
            +
              });
         | 
| 143 | 
            +
             | 
| 144 | 
            +
              it("should display the stop button and trigger the callback", async () => {
         | 
| 145 | 
            +
                const user = userEvent.setup();
         | 
| 146 | 
            +
                const onStopMock = vi.fn();
         | 
| 147 | 
            +
                render(
         | 
| 148 | 
            +
                  <ChatInput onSubmit={onSubmitMock} button="stop" onStop={onStopMock} />,
         | 
| 149 | 
            +
                );
         | 
| 150 | 
            +
                const stopButton = screen.getByTestId("stop-button");
         | 
| 151 | 
            +
             | 
| 152 | 
            +
                await user.click(stopButton);
         | 
| 153 | 
            +
                expect(onStopMock).toHaveBeenCalledOnce();
         | 
| 154 | 
            +
              });
         | 
| 155 | 
            +
             | 
| 156 | 
            +
              it("should call onFocus and onBlur when the textarea is focused and blurred", async () => {
         | 
| 157 | 
            +
                const user = userEvent.setup();
         | 
| 158 | 
            +
                const onFocusMock = vi.fn();
         | 
| 159 | 
            +
                const onBlurMock = vi.fn();
         | 
| 160 | 
            +
                render(
         | 
| 161 | 
            +
                  <ChatInput
         | 
| 162 | 
            +
                    onSubmit={onSubmitMock}
         | 
| 163 | 
            +
                    onFocus={onFocusMock}
         | 
| 164 | 
            +
                    onBlur={onBlurMock}
         | 
| 165 | 
            +
                  />,
         | 
| 166 | 
            +
                );
         | 
| 167 | 
            +
                const textarea = screen.getByRole("textbox");
         | 
| 168 | 
            +
             | 
| 169 | 
            +
                await user.click(textarea);
         | 
| 170 | 
            +
                expect(onFocusMock).toHaveBeenCalledOnce();
         | 
| 171 | 
            +
             | 
| 172 | 
            +
                await user.tab();
         | 
| 173 | 
            +
                expect(onBlurMock).toHaveBeenCalledOnce();
         | 
| 174 | 
            +
              });
         | 
| 175 | 
            +
             | 
| 176 | 
            +
              it("should handle text paste correctly", () => {
         | 
| 177 | 
            +
                const onSubmit = vi.fn();
         | 
| 178 | 
            +
                const onChange = vi.fn();
         | 
| 179 | 
            +
             | 
| 180 | 
            +
                render(<ChatInput onSubmit={onSubmit} onChange={onChange} />);
         | 
| 181 | 
            +
             | 
| 182 | 
            +
                const input = screen.getByTestId("chat-input").querySelector("textarea");
         | 
| 183 | 
            +
                expect(input).toBeTruthy();
         | 
| 184 | 
            +
             | 
| 185 | 
            +
                // Fire paste event with text data
         | 
| 186 | 
            +
                fireEvent.paste(input!, {
         | 
| 187 | 
            +
                  clipboardData: {
         | 
| 188 | 
            +
                    getData: (type: string) => (type === "text/plain" ? "test paste" : ""),
         | 
| 189 | 
            +
                    files: [],
         | 
| 190 | 
            +
                  },
         | 
| 191 | 
            +
                });
         | 
| 192 | 
            +
              });
         | 
| 193 | 
            +
             | 
| 194 | 
            +
              it("should handle image paste correctly", () => {
         | 
| 195 | 
            +
                const onSubmit = vi.fn();
         | 
| 196 | 
            +
                const onImagePaste = vi.fn();
         | 
| 197 | 
            +
             | 
| 198 | 
            +
                render(<ChatInput onSubmit={onSubmit} onImagePaste={onImagePaste} />);
         | 
| 199 | 
            +
             | 
| 200 | 
            +
                const input = screen.getByTestId("chat-input").querySelector("textarea");
         | 
| 201 | 
            +
                expect(input).toBeTruthy();
         | 
| 202 | 
            +
             | 
| 203 | 
            +
                // Create a paste event with an image file
         | 
| 204 | 
            +
                const file = new File(["dummy content"], "image.png", {
         | 
| 205 | 
            +
                  type: "image/png",
         | 
| 206 | 
            +
                });
         | 
| 207 | 
            +
             | 
| 208 | 
            +
                // Fire paste event with image data
         | 
| 209 | 
            +
                fireEvent.paste(input!, {
         | 
| 210 | 
            +
                  clipboardData: {
         | 
| 211 | 
            +
                    getData: () => "",
         | 
| 212 | 
            +
                    files: [file],
         | 
| 213 | 
            +
                  },
         | 
| 214 | 
            +
                });
         | 
| 215 | 
            +
             | 
| 216 | 
            +
                // Verify image paste was handled
         | 
| 217 | 
            +
                expect(onImagePaste).toHaveBeenCalledWith([file]);
         | 
| 218 | 
            +
              });
         | 
| 219 | 
            +
             | 
| 220 | 
            +
              it("should use the default maxRows value", () => {
         | 
| 221 | 
            +
                // We can't directly test the maxRows prop as it's not exposed in the DOM
         | 
| 222 | 
            +
                // Instead, we'll verify the component renders with the default props
         | 
| 223 | 
            +
                render(<ChatInput onSubmit={onSubmitMock} />);
         | 
| 224 | 
            +
                const textarea = screen.getByRole("textbox");
         | 
| 225 | 
            +
                expect(textarea).toBeInTheDocument();
         | 
| 226 | 
            +
             | 
| 227 | 
            +
                // The actual verification of maxRows=16 is handled internally by the TextareaAutosize component
         | 
| 228 | 
            +
                // and affects how many rows the textarea can expand to
         | 
| 229 | 
            +
              });
         | 
| 230 | 
            +
             | 
| 231 | 
            +
              it("should not submit when Enter is pressed during IME composition", async () => {
         | 
| 232 | 
            +
                const user = userEvent.setup();
         | 
| 233 | 
            +
                render(<ChatInput onSubmit={onSubmitMock} />);
         | 
| 234 | 
            +
                const textarea = screen.getByRole("textbox");
         | 
| 235 | 
            +
             | 
| 236 | 
            +
                await user.type(textarea, "こんにちは");
         | 
| 237 | 
            +
             | 
| 238 | 
            +
                // Simulate Enter during IME composition
         | 
| 239 | 
            +
                fireEvent.keyDown(textarea, {
         | 
| 240 | 
            +
                  key: "Enter",
         | 
| 241 | 
            +
                  isComposing: true,
         | 
| 242 | 
            +
                  nativeEvent: { isComposing: true },
         | 
| 243 | 
            +
                });
         | 
| 244 | 
            +
             | 
| 245 | 
            +
                expect(onSubmitMock).not.toHaveBeenCalled();
         | 
| 246 | 
            +
             | 
| 247 | 
            +
                // Simulate normal Enter after composition is done
         | 
| 248 | 
            +
                fireEvent.keyDown(textarea, {
         | 
| 249 | 
            +
                  key: "Enter",
         | 
| 250 | 
            +
                  isComposing: false,
         | 
| 251 | 
            +
                  nativeEvent: { isComposing: false },
         | 
| 252 | 
            +
                });
         | 
| 253 | 
            +
             | 
| 254 | 
            +
                expect(onSubmitMock).toHaveBeenCalledWith("こんにちは");
         | 
| 255 | 
            +
              });
         | 
| 256 | 
            +
            });
         | 
    	
        frontend/__tests__/components/chat/chat-interface.test.tsx
    ADDED
    
    | @@ -0,0 +1,366 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
         | 
| 2 | 
            +
            import { screen, waitFor, within } from "@testing-library/react";
         | 
| 3 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 4 | 
            +
            import { renderWithProviders } from "test-utils";
         | 
| 5 | 
            +
            import type { Message } from "#/message";
         | 
| 6 | 
            +
            import { SUGGESTIONS } from "#/utils/suggestions";
         | 
| 7 | 
            +
            import { WsClientProviderStatus } from "#/context/ws-client-provider";
         | 
| 8 | 
            +
            import { ChatInterface } from "#/components/features/chat/chat-interface";
         | 
| 9 | 
            +
             | 
| 10 | 
            +
            // eslint-disable-next-line @typescript-eslint/no-unused-vars
         | 
| 11 | 
            +
            const renderChatInterface = (messages: Message[]) =>
         | 
| 12 | 
            +
              renderWithProviders(<ChatInterface />);
         | 
| 13 | 
            +
             | 
| 14 | 
            +
            describe("Empty state", () => {
         | 
| 15 | 
            +
              const { send: sendMock } = vi.hoisted(() => ({
         | 
| 16 | 
            +
                send: vi.fn(),
         | 
| 17 | 
            +
              }));
         | 
| 18 | 
            +
             | 
| 19 | 
            +
              const { useWsClient: useWsClientMock } = vi.hoisted(() => ({
         | 
| 20 | 
            +
                useWsClient: vi.fn(() => ({
         | 
| 21 | 
            +
                  send: sendMock,
         | 
| 22 | 
            +
                  status: WsClientProviderStatus.CONNECTED,
         | 
| 23 | 
            +
                  isLoadingMessages: false,
         | 
| 24 | 
            +
                })),
         | 
| 25 | 
            +
              }));
         | 
| 26 | 
            +
             | 
| 27 | 
            +
              beforeAll(() => {
         | 
| 28 | 
            +
                vi.mock("react-router", async (importActual) => ({
         | 
| 29 | 
            +
                  ...(await importActual<typeof import("react-router")>()),
         | 
| 30 | 
            +
                  useRouteLoaderData: vi.fn(() => ({})),
         | 
| 31 | 
            +
                }));
         | 
| 32 | 
            +
             | 
| 33 | 
            +
                vi.mock("#/context/socket", async (importActual) => ({
         | 
| 34 | 
            +
                  ...(await importActual<typeof import("#/context/ws-client-provider")>()),
         | 
| 35 | 
            +
                  useWsClient: useWsClientMock,
         | 
| 36 | 
            +
                }));
         | 
| 37 | 
            +
              });
         | 
| 38 | 
            +
             | 
| 39 | 
            +
              afterEach(() => {
         | 
| 40 | 
            +
                vi.clearAllMocks();
         | 
| 41 | 
            +
              });
         | 
| 42 | 
            +
             | 
| 43 | 
            +
              it.todo("should render suggestions if empty");
         | 
| 44 | 
            +
             | 
| 45 | 
            +
              it("should render the default suggestions", () => {
         | 
| 46 | 
            +
                renderWithProviders(<ChatInterface />);
         | 
| 47 | 
            +
             | 
| 48 | 
            +
                const suggestions = screen.getByTestId("suggestions");
         | 
| 49 | 
            +
                const repoSuggestions = Object.keys(SUGGESTIONS.repo);
         | 
| 50 | 
            +
             | 
| 51 | 
            +
                // check that there are at most 4 suggestions displayed
         | 
| 52 | 
            +
                const displayedSuggestions = within(suggestions).getAllByRole("button");
         | 
| 53 | 
            +
                expect(displayedSuggestions.length).toBeLessThanOrEqual(4);
         | 
| 54 | 
            +
             | 
| 55 | 
            +
                // Check that each displayed suggestion is one of the repo suggestions
         | 
| 56 | 
            +
                displayedSuggestions.forEach((suggestion) => {
         | 
| 57 | 
            +
                  expect(repoSuggestions).toContain(suggestion.textContent);
         | 
| 58 | 
            +
                });
         | 
| 59 | 
            +
              });
         | 
| 60 | 
            +
             | 
| 61 | 
            +
              it.fails(
         | 
| 62 | 
            +
                "should load the a user message to the input when selecting",
         | 
| 63 | 
            +
                async () => {
         | 
| 64 | 
            +
                  // this is to test that the message is in the UI before the socket is called
         | 
| 65 | 
            +
                  useWsClientMock.mockImplementation(() => ({
         | 
| 66 | 
            +
                    send: sendMock,
         | 
| 67 | 
            +
                    status: WsClientProviderStatus.CONNECTED,
         | 
| 68 | 
            +
                    isLoadingMessages: false,
         | 
| 69 | 
            +
                  }));
         | 
| 70 | 
            +
                  const user = userEvent.setup();
         | 
| 71 | 
            +
                  renderWithProviders(<ChatInterface />);
         | 
| 72 | 
            +
             | 
| 73 | 
            +
                  const suggestions = screen.getByTestId("suggestions");
         | 
| 74 | 
            +
                  const displayedSuggestions = within(suggestions).getAllByRole("button");
         | 
| 75 | 
            +
                  const input = screen.getByTestId("chat-input");
         | 
| 76 | 
            +
             | 
| 77 | 
            +
                  await user.click(displayedSuggestions[0]);
         | 
| 78 | 
            +
             | 
| 79 | 
            +
                  // user message loaded to input
         | 
| 80 | 
            +
                  expect(screen.queryByTestId("suggestions")).toBeInTheDocument();
         | 
| 81 | 
            +
                  expect(input).toHaveValue(displayedSuggestions[0].textContent);
         | 
| 82 | 
            +
                },
         | 
| 83 | 
            +
              );
         | 
| 84 | 
            +
             | 
| 85 | 
            +
              it.fails(
         | 
| 86 | 
            +
                "should send the message to the socket only if the runtime is active",
         | 
| 87 | 
            +
                async () => {
         | 
| 88 | 
            +
                  useWsClientMock.mockImplementation(() => ({
         | 
| 89 | 
            +
                    send: sendMock,
         | 
| 90 | 
            +
                    status: WsClientProviderStatus.CONNECTED,
         | 
| 91 | 
            +
                    isLoadingMessages: false,
         | 
| 92 | 
            +
                  }));
         | 
| 93 | 
            +
                  const user = userEvent.setup();
         | 
| 94 | 
            +
                  const { rerender } = renderWithProviders(<ChatInterface />);
         | 
| 95 | 
            +
             | 
| 96 | 
            +
                  const suggestions = screen.getByTestId("suggestions");
         | 
| 97 | 
            +
                  const displayedSuggestions = within(suggestions).getAllByRole("button");
         | 
| 98 | 
            +
             | 
| 99 | 
            +
                  await user.click(displayedSuggestions[0]);
         | 
| 100 | 
            +
                  expect(sendMock).not.toHaveBeenCalled();
         | 
| 101 | 
            +
             | 
| 102 | 
            +
                  useWsClientMock.mockImplementation(() => ({
         | 
| 103 | 
            +
                    send: sendMock,
         | 
| 104 | 
            +
                    status: WsClientProviderStatus.CONNECTED,
         | 
| 105 | 
            +
                    isLoadingMessages: false,
         | 
| 106 | 
            +
                  }));
         | 
| 107 | 
            +
                  rerender(<ChatInterface />);
         | 
| 108 | 
            +
             | 
| 109 | 
            +
                  await waitFor(() =>
         | 
| 110 | 
            +
                    expect(sendMock).toHaveBeenCalledWith(expect.any(String)),
         | 
| 111 | 
            +
                  );
         | 
| 112 | 
            +
                },
         | 
| 113 | 
            +
              );
         | 
| 114 | 
            +
            });
         | 
| 115 | 
            +
             | 
| 116 | 
            +
            describe.skip("ChatInterface", () => {
         | 
| 117 | 
            +
              beforeAll(() => {
         | 
| 118 | 
            +
                // mock useScrollToBottom hook
         | 
| 119 | 
            +
                vi.mock("#/hooks/useScrollToBottom", () => ({
         | 
| 120 | 
            +
                  useScrollToBottom: vi.fn(() => ({
         | 
| 121 | 
            +
                    scrollDomToBottom: vi.fn(),
         | 
| 122 | 
            +
                    onChatBodyScroll: vi.fn(),
         | 
| 123 | 
            +
                    hitBottom: vi.fn(),
         | 
| 124 | 
            +
                  })),
         | 
| 125 | 
            +
                }));
         | 
| 126 | 
            +
              });
         | 
| 127 | 
            +
             | 
| 128 | 
            +
              afterEach(() => {
         | 
| 129 | 
            +
                vi.clearAllMocks();
         | 
| 130 | 
            +
              });
         | 
| 131 | 
            +
             | 
| 132 | 
            +
              it("should render messages", () => {
         | 
| 133 | 
            +
                const messages: Message[] = [
         | 
| 134 | 
            +
                  {
         | 
| 135 | 
            +
                    sender: "user",
         | 
| 136 | 
            +
                    content: "Hello",
         | 
| 137 | 
            +
                    imageUrls: [],
         | 
| 138 | 
            +
                    timestamp: new Date().toISOString(),
         | 
| 139 | 
            +
                    pending: true,
         | 
| 140 | 
            +
                  },
         | 
| 141 | 
            +
                  {
         | 
| 142 | 
            +
                    sender: "assistant",
         | 
| 143 | 
            +
                    content: "Hi",
         | 
| 144 | 
            +
                    imageUrls: [],
         | 
| 145 | 
            +
                    timestamp: new Date().toISOString(),
         | 
| 146 | 
            +
                    pending: true,
         | 
| 147 | 
            +
                  },
         | 
| 148 | 
            +
                ];
         | 
| 149 | 
            +
                renderChatInterface(messages);
         | 
| 150 | 
            +
             | 
| 151 | 
            +
                expect(screen.getAllByTestId(/-message/)).toHaveLength(2);
         | 
| 152 | 
            +
              });
         | 
| 153 | 
            +
             | 
| 154 | 
            +
              it("should render a chat input", () => {
         | 
| 155 | 
            +
                const messages: Message[] = [];
         | 
| 156 | 
            +
                renderChatInterface(messages);
         | 
| 157 | 
            +
             | 
| 158 | 
            +
                expect(screen.getByTestId("chat-input")).toBeInTheDocument();
         | 
| 159 | 
            +
              });
         | 
| 160 | 
            +
             | 
| 161 | 
            +
              it("should call socket send when submitting a message", async () => {
         | 
| 162 | 
            +
                const user = userEvent.setup();
         | 
| 163 | 
            +
                const messages: Message[] = [];
         | 
| 164 | 
            +
                renderChatInterface(messages);
         | 
| 165 | 
            +
             | 
| 166 | 
            +
                const input = screen.getByTestId("chat-input");
         | 
| 167 | 
            +
                await user.type(input, "Hello");
         | 
| 168 | 
            +
                await user.keyboard("{Enter}");
         | 
| 169 | 
            +
             | 
| 170 | 
            +
                // spy on send and expect to have been called
         | 
| 171 | 
            +
              });
         | 
| 172 | 
            +
             | 
| 173 | 
            +
              it("should render an image carousel with a message", () => {
         | 
| 174 | 
            +
                let messages: Message[] = [
         | 
| 175 | 
            +
                  {
         | 
| 176 | 
            +
                    sender: "assistant",
         | 
| 177 | 
            +
                    content: "Here are some images",
         | 
| 178 | 
            +
                    imageUrls: [],
         | 
| 179 | 
            +
                    timestamp: new Date().toISOString(),
         | 
| 180 | 
            +
                    pending: true,
         | 
| 181 | 
            +
                  },
         | 
| 182 | 
            +
                ];
         | 
| 183 | 
            +
                const { rerender } = renderChatInterface(messages);
         | 
| 184 | 
            +
             | 
| 185 | 
            +
                expect(screen.queryByTestId("image-carousel")).not.toBeInTheDocument();
         | 
| 186 | 
            +
             | 
| 187 | 
            +
                messages = [
         | 
| 188 | 
            +
                  {
         | 
| 189 | 
            +
                    sender: "assistant",
         | 
| 190 | 
            +
                    content: "Here are some images",
         | 
| 191 | 
            +
                    imageUrls: ["image1", "image2"],
         | 
| 192 | 
            +
                    timestamp: new Date().toISOString(),
         | 
| 193 | 
            +
                    pending: true,
         | 
| 194 | 
            +
                  },
         | 
| 195 | 
            +
                ];
         | 
| 196 | 
            +
             | 
| 197 | 
            +
                rerender(<ChatInterface />);
         | 
| 198 | 
            +
             | 
| 199 | 
            +
                const imageCarousel = screen.getByTestId("image-carousel");
         | 
| 200 | 
            +
                expect(imageCarousel).toBeInTheDocument();
         | 
| 201 | 
            +
                expect(within(imageCarousel).getAllByTestId("image-preview")).toHaveLength(
         | 
| 202 | 
            +
                  2,
         | 
| 203 | 
            +
                );
         | 
| 204 | 
            +
              });
         | 
| 205 | 
            +
             | 
| 206 | 
            +
              it("should render a 'continue' action when there are more than 2 messages and awaiting user input", () => {
         | 
| 207 | 
            +
                const messages: Message[] = [
         | 
| 208 | 
            +
                  {
         | 
| 209 | 
            +
                    sender: "assistant",
         | 
| 210 | 
            +
                    content: "Hello",
         | 
| 211 | 
            +
                    imageUrls: [],
         | 
| 212 | 
            +
                    timestamp: new Date().toISOString(),
         | 
| 213 | 
            +
                    pending: true,
         | 
| 214 | 
            +
                  },
         | 
| 215 | 
            +
                  {
         | 
| 216 | 
            +
                    sender: "user",
         | 
| 217 | 
            +
                    content: "Hi",
         | 
| 218 | 
            +
                    imageUrls: [],
         | 
| 219 | 
            +
                    timestamp: new Date().toISOString(),
         | 
| 220 | 
            +
                    pending: true,
         | 
| 221 | 
            +
                  },
         | 
| 222 | 
            +
                ];
         | 
| 223 | 
            +
                const { rerender } = renderChatInterface(messages);
         | 
| 224 | 
            +
                expect(
         | 
| 225 | 
            +
                  screen.queryByTestId("continue-action-button"),
         | 
| 226 | 
            +
                ).not.toBeInTheDocument();
         | 
| 227 | 
            +
             | 
| 228 | 
            +
                messages.push({
         | 
| 229 | 
            +
                  sender: "assistant",
         | 
| 230 | 
            +
                  content: "How can I help you?",
         | 
| 231 | 
            +
                  imageUrls: [],
         | 
| 232 | 
            +
                  timestamp: new Date().toISOString(),
         | 
| 233 | 
            +
                  pending: true,
         | 
| 234 | 
            +
                });
         | 
| 235 | 
            +
             | 
| 236 | 
            +
                rerender(<ChatInterface />);
         | 
| 237 | 
            +
             | 
| 238 | 
            +
                expect(screen.getByTestId("continue-action-button")).toBeInTheDocument();
         | 
| 239 | 
            +
              });
         | 
| 240 | 
            +
             | 
| 241 | 
            +
              it("should render inline errors", () => {
         | 
| 242 | 
            +
                const messages: Message[] = [
         | 
| 243 | 
            +
                  {
         | 
| 244 | 
            +
                    sender: "assistant",
         | 
| 245 | 
            +
                    content: "Hello",
         | 
| 246 | 
            +
                    imageUrls: [],
         | 
| 247 | 
            +
                    timestamp: new Date().toISOString(),
         | 
| 248 | 
            +
                    pending: true,
         | 
| 249 | 
            +
                  },
         | 
| 250 | 
            +
                  {
         | 
| 251 | 
            +
                    type: "error",
         | 
| 252 | 
            +
                    content: "Something went wrong",
         | 
| 253 | 
            +
                    sender: "assistant",
         | 
| 254 | 
            +
                    timestamp: new Date().toISOString(),
         | 
| 255 | 
            +
                  },
         | 
| 256 | 
            +
                ];
         | 
| 257 | 
            +
                renderChatInterface(messages);
         | 
| 258 | 
            +
             | 
| 259 | 
            +
                const error = screen.getByTestId("error-message");
         | 
| 260 | 
            +
                expect(within(error).getByText("Something went wrong")).toBeInTheDocument();
         | 
| 261 | 
            +
              });
         | 
| 262 | 
            +
             | 
| 263 | 
            +
              it("should render both GitHub buttons initially when ghToken is available", () => {
         | 
| 264 | 
            +
                vi.mock("react-router", async (importActual) => ({
         | 
| 265 | 
            +
                  ...(await importActual<typeof import("react-router")>()),
         | 
| 266 | 
            +
                  useRouteLoaderData: vi.fn(() => ({ ghToken: "test-token" })),
         | 
| 267 | 
            +
                }));
         | 
| 268 | 
            +
             | 
| 269 | 
            +
                const messages: Message[] = [
         | 
| 270 | 
            +
                  {
         | 
| 271 | 
            +
                    sender: "assistant",
         | 
| 272 | 
            +
                    content: "Hello",
         | 
| 273 | 
            +
                    imageUrls: [],
         | 
| 274 | 
            +
                    timestamp: new Date().toISOString(),
         | 
| 275 | 
            +
                    pending: true,
         | 
| 276 | 
            +
                  },
         | 
| 277 | 
            +
                ];
         | 
| 278 | 
            +
                renderChatInterface(messages);
         | 
| 279 | 
            +
             | 
| 280 | 
            +
                const pushButton = screen.getByRole("button", { name: "Push to Branch" });
         | 
| 281 | 
            +
                const prButton = screen.getByRole("button", { name: "Push & Create PR" });
         | 
| 282 | 
            +
             | 
| 283 | 
            +
                expect(pushButton).toBeInTheDocument();
         | 
| 284 | 
            +
                expect(prButton).toBeInTheDocument();
         | 
| 285 | 
            +
                expect(pushButton).toHaveTextContent("Push to Branch");
         | 
| 286 | 
            +
                expect(prButton).toHaveTextContent("Push & Create PR");
         | 
| 287 | 
            +
              });
         | 
| 288 | 
            +
             | 
| 289 | 
            +
              it("should render only 'Push changes to PR' button after PR is created", async () => {
         | 
| 290 | 
            +
                vi.mock("react-router", async (importActual) => ({
         | 
| 291 | 
            +
                  ...(await importActual<typeof import("react-router")>()),
         | 
| 292 | 
            +
                  useRouteLoaderData: vi.fn(() => ({ ghToken: "test-token" })),
         | 
| 293 | 
            +
                }));
         | 
| 294 | 
            +
             | 
| 295 | 
            +
                const messages: Message[] = [
         | 
| 296 | 
            +
                  {
         | 
| 297 | 
            +
                    sender: "assistant",
         | 
| 298 | 
            +
                    content: "Hello",
         | 
| 299 | 
            +
                    imageUrls: [],
         | 
| 300 | 
            +
                    timestamp: new Date().toISOString(),
         | 
| 301 | 
            +
                    pending: true,
         | 
| 302 | 
            +
                  },
         | 
| 303 | 
            +
                ];
         | 
| 304 | 
            +
                const { rerender } = renderChatInterface(messages);
         | 
| 305 | 
            +
                const user = userEvent.setup();
         | 
| 306 | 
            +
             | 
| 307 | 
            +
                // Click the "Push & Create PR" button
         | 
| 308 | 
            +
                const prButton = screen.getByRole("button", { name: "Push & Create PR" });
         | 
| 309 | 
            +
                await user.click(prButton);
         | 
| 310 | 
            +
             | 
| 311 | 
            +
                // Re-render to trigger state update
         | 
| 312 | 
            +
                rerender(<ChatInterface />);
         | 
| 313 | 
            +
             | 
| 314 | 
            +
                // Verify only one button is shown
         | 
| 315 | 
            +
                const pushToPrButton = screen.getByRole("button", {
         | 
| 316 | 
            +
                  name: "Push changes to PR",
         | 
| 317 | 
            +
                });
         | 
| 318 | 
            +
                expect(pushToPrButton).toBeInTheDocument();
         | 
| 319 | 
            +
                expect(
         | 
| 320 | 
            +
                  screen.queryByRole("button", { name: "Push to Branch" }),
         | 
| 321 | 
            +
                ).not.toBeInTheDocument();
         | 
| 322 | 
            +
                expect(
         | 
| 323 | 
            +
                  screen.queryByRole("button", { name: "Push & Create PR" }),
         | 
| 324 | 
            +
                ).not.toBeInTheDocument();
         | 
| 325 | 
            +
              });
         | 
| 326 | 
            +
             | 
| 327 | 
            +
              it("should render feedback actions if there are more than 3 messages", () => {
         | 
| 328 | 
            +
                const messages: Message[] = [
         | 
| 329 | 
            +
                  {
         | 
| 330 | 
            +
                    sender: "assistant",
         | 
| 331 | 
            +
                    content: "Hello",
         | 
| 332 | 
            +
                    imageUrls: [],
         | 
| 333 | 
            +
                    timestamp: new Date().toISOString(),
         | 
| 334 | 
            +
                    pending: true,
         | 
| 335 | 
            +
                  },
         | 
| 336 | 
            +
                  {
         | 
| 337 | 
            +
                    sender: "user",
         | 
| 338 | 
            +
                    content: "Hi",
         | 
| 339 | 
            +
                    imageUrls: [],
         | 
| 340 | 
            +
                    timestamp: new Date().toISOString(),
         | 
| 341 | 
            +
                    pending: true,
         | 
| 342 | 
            +
                  },
         | 
| 343 | 
            +
                  {
         | 
| 344 | 
            +
                    sender: "assistant",
         | 
| 345 | 
            +
                    content: "How can I help you?",
         | 
| 346 | 
            +
                    imageUrls: [],
         | 
| 347 | 
            +
                    timestamp: new Date().toISOString(),
         | 
| 348 | 
            +
                    pending: true,
         | 
| 349 | 
            +
                  },
         | 
| 350 | 
            +
                ];
         | 
| 351 | 
            +
                const { rerender } = renderChatInterface(messages);
         | 
| 352 | 
            +
                expect(screen.queryByTestId("feedback-actions")).not.toBeInTheDocument();
         | 
| 353 | 
            +
             | 
| 354 | 
            +
                messages.push({
         | 
| 355 | 
            +
                  sender: "user",
         | 
| 356 | 
            +
                  content: "I need help",
         | 
| 357 | 
            +
                  imageUrls: [],
         | 
| 358 | 
            +
                  timestamp: new Date().toISOString(),
         | 
| 359 | 
            +
                  pending: true,
         | 
| 360 | 
            +
                });
         | 
| 361 | 
            +
             | 
| 362 | 
            +
                rerender(<ChatInterface />);
         | 
| 363 | 
            +
             | 
| 364 | 
            +
                expect(screen.getByTestId("feedback-actions")).toBeInTheDocument();
         | 
| 365 | 
            +
              });
         | 
| 366 | 
            +
            });
         | 
    	
        frontend/__tests__/components/chat/expandable-message.test.tsx
    ADDED
    
    | @@ -0,0 +1,141 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { describe, expect, it, vi } from "vitest";
         | 
| 2 | 
            +
            import { screen } from "@testing-library/react";
         | 
| 3 | 
            +
            import { renderWithProviders } from "test-utils";
         | 
| 4 | 
            +
            import { createRoutesStub } from "react-router";
         | 
| 5 | 
            +
            import { ExpandableMessage } from "#/components/features/chat/expandable-message";
         | 
| 6 | 
            +
            import OpenHands from "#/api/open-hands";
         | 
| 7 | 
            +
             | 
| 8 | 
            +
            vi.mock("react-i18next", async () => {
         | 
| 9 | 
            +
              const actual = await vi.importActual("react-i18next");
         | 
| 10 | 
            +
              return {
         | 
| 11 | 
            +
                ...actual,
         | 
| 12 | 
            +
                useTranslation: () => ({
         | 
| 13 | 
            +
                  t: (key: string) => key,
         | 
| 14 | 
            +
                  i18n: {
         | 
| 15 | 
            +
                    changeLanguage: () => new Promise(() => {}),
         | 
| 16 | 
            +
                    language: "en",
         | 
| 17 | 
            +
                    exists: () => true,
         | 
| 18 | 
            +
                  },
         | 
| 19 | 
            +
                }),
         | 
| 20 | 
            +
              };
         | 
| 21 | 
            +
            });
         | 
| 22 | 
            +
             | 
| 23 | 
            +
            describe("ExpandableMessage", () => {
         | 
| 24 | 
            +
              it("should render with neutral border for non-action messages", () => {
         | 
| 25 | 
            +
                renderWithProviders(<ExpandableMessage message="Hello" type="thought" />);
         | 
| 26 | 
            +
                const element = screen.getAllByText("Hello")[0];
         | 
| 27 | 
            +
                const container = element.closest(
         | 
| 28 | 
            +
                  "div.flex.gap-2.items-center.justify-start",
         | 
| 29 | 
            +
                );
         | 
| 30 | 
            +
                expect(container).toHaveClass("border-neutral-300");
         | 
| 31 | 
            +
                expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
         | 
| 32 | 
            +
              });
         | 
| 33 | 
            +
             | 
| 34 | 
            +
              it("should render with neutral border for error messages", () => {
         | 
| 35 | 
            +
                renderWithProviders(
         | 
| 36 | 
            +
                  <ExpandableMessage message="Error occurred" type="error" />,
         | 
| 37 | 
            +
                );
         | 
| 38 | 
            +
                const element = screen.getAllByText("Error occurred")[0];
         | 
| 39 | 
            +
                const container = element.closest(
         | 
| 40 | 
            +
                  "div.flex.gap-2.items-center.justify-start",
         | 
| 41 | 
            +
                );
         | 
| 42 | 
            +
                expect(container).toHaveClass("border-danger");
         | 
| 43 | 
            +
                expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
         | 
| 44 | 
            +
              });
         | 
| 45 | 
            +
             | 
| 46 | 
            +
              it("should render with success icon for successful action messages", () => {
         | 
| 47 | 
            +
                renderWithProviders(
         | 
| 48 | 
            +
                  <ExpandableMessage
         | 
| 49 | 
            +
                    id="OBSERVATION_MESSAGE$RUN"
         | 
| 50 | 
            +
                    message="Command executed successfully"
         | 
| 51 | 
            +
                    type="action"
         | 
| 52 | 
            +
                    success
         | 
| 53 | 
            +
                  />,
         | 
| 54 | 
            +
                );
         | 
| 55 | 
            +
                const element = screen.getByText("OBSERVATION_MESSAGE$RUN");
         | 
| 56 | 
            +
                const container = element.closest(
         | 
| 57 | 
            +
                  "div.flex.gap-2.items-center.justify-start",
         | 
| 58 | 
            +
                );
         | 
| 59 | 
            +
                expect(container).toHaveClass("border-neutral-300");
         | 
| 60 | 
            +
                const icon = screen.getByTestId("status-icon");
         | 
| 61 | 
            +
                expect(icon).toHaveClass("fill-success");
         | 
| 62 | 
            +
              });
         | 
| 63 | 
            +
             | 
| 64 | 
            +
              it("should render with error icon for failed action messages", () => {
         | 
| 65 | 
            +
                renderWithProviders(
         | 
| 66 | 
            +
                  <ExpandableMessage
         | 
| 67 | 
            +
                    id="OBSERVATION_MESSAGE$RUN"
         | 
| 68 | 
            +
                    message="Command failed"
         | 
| 69 | 
            +
                    type="action"
         | 
| 70 | 
            +
                    success={false}
         | 
| 71 | 
            +
                  />,
         | 
| 72 | 
            +
                );
         | 
| 73 | 
            +
                const element = screen.getByText("OBSERVATION_MESSAGE$RUN");
         | 
| 74 | 
            +
                const container = element.closest(
         | 
| 75 | 
            +
                  "div.flex.gap-2.items-center.justify-start",
         | 
| 76 | 
            +
                );
         | 
| 77 | 
            +
                expect(container).toHaveClass("border-neutral-300");
         | 
| 78 | 
            +
                const icon = screen.getByTestId("status-icon");
         | 
| 79 | 
            +
                expect(icon).toHaveClass("fill-danger");
         | 
| 80 | 
            +
              });
         | 
| 81 | 
            +
             | 
| 82 | 
            +
              it("should render with neutral border and no icon for action messages without success prop", () => {
         | 
| 83 | 
            +
                renderWithProviders(
         | 
| 84 | 
            +
                  <ExpandableMessage
         | 
| 85 | 
            +
                    id="OBSERVATION_MESSAGE$RUN"
         | 
| 86 | 
            +
                    message="Running command"
         | 
| 87 | 
            +
                    type="action"
         | 
| 88 | 
            +
                  />,
         | 
| 89 | 
            +
                );
         | 
| 90 | 
            +
                const element = screen.getByText("OBSERVATION_MESSAGE$RUN");
         | 
| 91 | 
            +
                const container = element.closest(
         | 
| 92 | 
            +
                  "div.flex.gap-2.items-center.justify-start",
         | 
| 93 | 
            +
                );
         | 
| 94 | 
            +
                expect(container).toHaveClass("border-neutral-300");
         | 
| 95 | 
            +
                expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
         | 
| 96 | 
            +
              });
         | 
| 97 | 
            +
             | 
| 98 | 
            +
              it("should render with neutral border and no icon for action messages with undefined success (timeout case)", () => {
         | 
| 99 | 
            +
                renderWithProviders(
         | 
| 100 | 
            +
                  <ExpandableMessage
         | 
| 101 | 
            +
                    id="OBSERVATION_MESSAGE$RUN"
         | 
| 102 | 
            +
                    message="Command timed out"
         | 
| 103 | 
            +
                    type="action"
         | 
| 104 | 
            +
                    success={undefined}
         | 
| 105 | 
            +
                  />,
         | 
| 106 | 
            +
                );
         | 
| 107 | 
            +
                const element = screen.getByText("OBSERVATION_MESSAGE$RUN");
         | 
| 108 | 
            +
                const container = element.closest(
         | 
| 109 | 
            +
                  "div.flex.gap-2.items-center.justify-start",
         | 
| 110 | 
            +
                );
         | 
| 111 | 
            +
                expect(container).toHaveClass("border-neutral-300");
         | 
| 112 | 
            +
                expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
         | 
| 113 | 
            +
              });
         | 
| 114 | 
            +
             | 
| 115 | 
            +
              it("should render the out of credits message when the user is out of credits", async () => {
         | 
| 116 | 
            +
                const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
         | 
| 117 | 
            +
                // @ts-expect-error - We only care about the APP_MODE and FEATURE_FLAGS fields
         | 
| 118 | 
            +
                getConfigSpy.mockResolvedValue({
         | 
| 119 | 
            +
                  APP_MODE: "saas",
         | 
| 120 | 
            +
                  FEATURE_FLAGS: {
         | 
| 121 | 
            +
                    ENABLE_BILLING: true,
         | 
| 122 | 
            +
                    HIDE_LLM_SETTINGS: false,
         | 
| 123 | 
            +
                  },
         | 
| 124 | 
            +
                });
         | 
| 125 | 
            +
                const RouterStub = createRoutesStub([
         | 
| 126 | 
            +
                  {
         | 
| 127 | 
            +
                    Component: () => (
         | 
| 128 | 
            +
                      <ExpandableMessage
         | 
| 129 | 
            +
                        id="STATUS$ERROR_LLM_OUT_OF_CREDITS"
         | 
| 130 | 
            +
                        message=""
         | 
| 131 | 
            +
                        type=""
         | 
| 132 | 
            +
                      />
         | 
| 133 | 
            +
                    ),
         | 
| 134 | 
            +
                    path: "/",
         | 
| 135 | 
            +
                  },
         | 
| 136 | 
            +
                ]);
         | 
| 137 | 
            +
             | 
| 138 | 
            +
                renderWithProviders(<RouterStub />);
         | 
| 139 | 
            +
                await screen.findByTestId("out-of-credits");
         | 
| 140 | 
            +
              });
         | 
| 141 | 
            +
            });
         | 
    	
        frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx
    ADDED
    
    | @@ -0,0 +1,74 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { render, screen } from "@testing-library/react";
         | 
| 2 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 3 | 
            +
            import { afterEach, describe, expect, it, test, vi } from "vitest";
         | 
| 4 | 
            +
            import { AccountSettingsContextMenu } from "#/components/features/context-menu/account-settings-context-menu";
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            describe("AccountSettingsContextMenu", () => {
         | 
| 7 | 
            +
              const user = userEvent.setup();
         | 
| 8 | 
            +
              const onClickAccountSettingsMock = vi.fn();
         | 
| 9 | 
            +
              const onLogoutMock = vi.fn();
         | 
| 10 | 
            +
              const onCloseMock = vi.fn();
         | 
| 11 | 
            +
             | 
| 12 | 
            +
              afterEach(() => {
         | 
| 13 | 
            +
                onClickAccountSettingsMock.mockClear();
         | 
| 14 | 
            +
                onLogoutMock.mockClear();
         | 
| 15 | 
            +
                onCloseMock.mockClear();
         | 
| 16 | 
            +
              });
         | 
| 17 | 
            +
             | 
| 18 | 
            +
              it("should always render the right options", () => {
         | 
| 19 | 
            +
                render(
         | 
| 20 | 
            +
                  <AccountSettingsContextMenu
         | 
| 21 | 
            +
                    onLogout={onLogoutMock}
         | 
| 22 | 
            +
                    onClose={onCloseMock}
         | 
| 23 | 
            +
                  />,
         | 
| 24 | 
            +
                );
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                expect(
         | 
| 27 | 
            +
                  screen.getByTestId("account-settings-context-menu"),
         | 
| 28 | 
            +
                ).toBeInTheDocument();
         | 
| 29 | 
            +
                expect(screen.getByText("ACCOUNT_SETTINGS$LOGOUT")).toBeInTheDocument();
         | 
| 30 | 
            +
              });
         | 
| 31 | 
            +
             | 
| 32 | 
            +
              it("should call onLogout when the logout option is clicked", async () => {
         | 
| 33 | 
            +
                render(
         | 
| 34 | 
            +
                  <AccountSettingsContextMenu
         | 
| 35 | 
            +
                    onLogout={onLogoutMock}
         | 
| 36 | 
            +
                    onClose={onCloseMock}
         | 
| 37 | 
            +
                  />,
         | 
| 38 | 
            +
                );
         | 
| 39 | 
            +
             | 
| 40 | 
            +
                const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
         | 
| 41 | 
            +
                await user.click(logoutOption);
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                expect(onLogoutMock).toHaveBeenCalledOnce();
         | 
| 44 | 
            +
              });
         | 
| 45 | 
            +
             | 
| 46 | 
            +
              test("logout button is always enabled", async () => {
         | 
| 47 | 
            +
                render(
         | 
| 48 | 
            +
                  <AccountSettingsContextMenu
         | 
| 49 | 
            +
                    onLogout={onLogoutMock}
         | 
| 50 | 
            +
                    onClose={onCloseMock}
         | 
| 51 | 
            +
                  />,
         | 
| 52 | 
            +
                );
         | 
| 53 | 
            +
             | 
| 54 | 
            +
                const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
         | 
| 55 | 
            +
                await user.click(logoutOption);
         | 
| 56 | 
            +
             | 
| 57 | 
            +
                expect(onLogoutMock).toHaveBeenCalledOnce();
         | 
| 58 | 
            +
              });
         | 
| 59 | 
            +
             | 
| 60 | 
            +
              it("should call onClose when clicking outside of the element", async () => {
         | 
| 61 | 
            +
                render(
         | 
| 62 | 
            +
                  <AccountSettingsContextMenu
         | 
| 63 | 
            +
                    onLogout={onLogoutMock}
         | 
| 64 | 
            +
                    onClose={onCloseMock}
         | 
| 65 | 
            +
                  />,
         | 
| 66 | 
            +
                );
         | 
| 67 | 
            +
             | 
| 68 | 
            +
                const accountSettingsButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
         | 
| 69 | 
            +
                await user.click(accountSettingsButton);
         | 
| 70 | 
            +
                await user.click(document.body);
         | 
| 71 | 
            +
             | 
| 72 | 
            +
                expect(onCloseMock).toHaveBeenCalledOnce();
         | 
| 73 | 
            +
              });
         | 
| 74 | 
            +
            });
         | 
    	
        frontend/__tests__/components/context-menu/context-menu-list-item.test.tsx
    ADDED
    
    | @@ -0,0 +1,44 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { describe, it, expect, vi } from "vitest";
         | 
| 2 | 
            +
            import { render, screen } from "@testing-library/react";
         | 
| 3 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 4 | 
            +
            import { ContextMenuListItem } from "#/components/features/context-menu/context-menu-list-item";
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            describe("ContextMenuListItem", () => {
         | 
| 7 | 
            +
              it("should render the component with the children", () => {
         | 
| 8 | 
            +
                const onClickMock = vi.fn();
         | 
| 9 | 
            +
                render(
         | 
| 10 | 
            +
                  <ContextMenuListItem onClick={onClickMock}>Test</ContextMenuListItem>,
         | 
| 11 | 
            +
                );
         | 
| 12 | 
            +
             | 
| 13 | 
            +
                expect(screen.getByTestId("context-menu-list-item")).toBeInTheDocument();
         | 
| 14 | 
            +
                expect(screen.getByText("Test")).toBeInTheDocument();
         | 
| 15 | 
            +
              });
         | 
| 16 | 
            +
             | 
| 17 | 
            +
              it("should call the onClick callback when clicked", async () => {
         | 
| 18 | 
            +
                const user = userEvent.setup();
         | 
| 19 | 
            +
                const onClickMock = vi.fn();
         | 
| 20 | 
            +
                render(
         | 
| 21 | 
            +
                  <ContextMenuListItem onClick={onClickMock}>Test</ContextMenuListItem>,
         | 
| 22 | 
            +
                );
         | 
| 23 | 
            +
             | 
| 24 | 
            +
                const element = screen.getByTestId("context-menu-list-item");
         | 
| 25 | 
            +
                await user.click(element);
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                expect(onClickMock).toHaveBeenCalledOnce();
         | 
| 28 | 
            +
              });
         | 
| 29 | 
            +
             | 
| 30 | 
            +
              it("should not call the onClick callback when clicked and the button is disabled", async () => {
         | 
| 31 | 
            +
                const user = userEvent.setup();
         | 
| 32 | 
            +
                const onClickMock = vi.fn();
         | 
| 33 | 
            +
                render(
         | 
| 34 | 
            +
                  <ContextMenuListItem onClick={onClickMock} isDisabled>
         | 
| 35 | 
            +
                    Test
         | 
| 36 | 
            +
                  </ContextMenuListItem>,
         | 
| 37 | 
            +
                );
         | 
| 38 | 
            +
             | 
| 39 | 
            +
                const element = screen.getByTestId("context-menu-list-item");
         | 
| 40 | 
            +
                await user.click(element);
         | 
| 41 | 
            +
             | 
| 42 | 
            +
                expect(onClickMock).not.toHaveBeenCalled();
         | 
| 43 | 
            +
              });
         | 
| 44 | 
            +
            });
         | 
    	
        frontend/__tests__/components/features/analytics/analytics-consent-form-modal.test.tsx
    ADDED
    
    | @@ -0,0 +1,30 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 2 | 
            +
            import { describe, expect, it, vi } from "vitest";
         | 
| 3 | 
            +
            import { render, screen, waitFor } from "@testing-library/react";
         | 
| 4 | 
            +
            import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
         | 
| 5 | 
            +
            import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
         | 
| 6 | 
            +
            import OpenHands from "#/api/open-hands";
         | 
| 7 | 
            +
             | 
| 8 | 
            +
            describe("AnalyticsConsentFormModal", () => {
         | 
| 9 | 
            +
              it("should call saveUserSettings with consent", async () => {
         | 
| 10 | 
            +
                const user = userEvent.setup();
         | 
| 11 | 
            +
                const onCloseMock = vi.fn();
         | 
| 12 | 
            +
                const saveUserSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                render(<AnalyticsConsentFormModal onClose={onCloseMock} />, {
         | 
| 15 | 
            +
                  wrapper: ({ children }) => (
         | 
| 16 | 
            +
                    <QueryClientProvider client={new QueryClient()}>
         | 
| 17 | 
            +
                      {children}
         | 
| 18 | 
            +
                    </QueryClientProvider>
         | 
| 19 | 
            +
                  ),
         | 
| 20 | 
            +
                });
         | 
| 21 | 
            +
             | 
| 22 | 
            +
                const confirmButton = screen.getByTestId("confirm-preferences");
         | 
| 23 | 
            +
                await user.click(confirmButton);
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                expect(saveUserSettingsSpy).toHaveBeenCalledWith(
         | 
| 26 | 
            +
                  expect.objectContaining({ user_consents_to_analytics: true }),
         | 
| 27 | 
            +
                );
         | 
| 28 | 
            +
                await waitFor(() => expect(onCloseMock).toHaveBeenCalled());
         | 
| 29 | 
            +
              });
         | 
| 30 | 
            +
            });
         | 
    	
        frontend/__tests__/components/features/auth-modal.test.tsx
    ADDED
    
    | @@ -0,0 +1,47 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { render, screen } from "@testing-library/react";
         | 
| 2 | 
            +
            import { it, describe, expect, vi, beforeEach, afterEach } from "vitest";
         | 
| 3 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 4 | 
            +
            import { AuthModal } from "#/components/features/waitlist/auth-modal";
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            // Mock the useAuthUrl hook
         | 
| 7 | 
            +
            vi.mock("#/hooks/use-auth-url", () => ({
         | 
| 8 | 
            +
              useAuthUrl: () => "https://gitlab.com/oauth/authorize",
         | 
| 9 | 
            +
            }));
         | 
| 10 | 
            +
             | 
| 11 | 
            +
            describe("AuthModal", () => {
         | 
| 12 | 
            +
              beforeEach(() => {
         | 
| 13 | 
            +
                vi.stubGlobal("location", { href: "" });
         | 
| 14 | 
            +
              });
         | 
| 15 | 
            +
             | 
| 16 | 
            +
              afterEach(() => {
         | 
| 17 | 
            +
                vi.unstubAllGlobals();
         | 
| 18 | 
            +
                vi.resetAllMocks();
         | 
| 19 | 
            +
              });
         | 
| 20 | 
            +
             | 
| 21 | 
            +
              it("should render the GitHub and GitLab buttons", () => {
         | 
| 22 | 
            +
                render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
         | 
| 23 | 
            +
             | 
| 24 | 
            +
                const githubButton = screen.getByRole("button", {
         | 
| 25 | 
            +
                  name: "GITHUB$CONNECT_TO_GITHUB",
         | 
| 26 | 
            +
                });
         | 
| 27 | 
            +
                const gitlabButton = screen.getByRole("button", {
         | 
| 28 | 
            +
                  name: "GITLAB$CONNECT_TO_GITLAB",
         | 
| 29 | 
            +
                });
         | 
| 30 | 
            +
             | 
| 31 | 
            +
                expect(githubButton).toBeInTheDocument();
         | 
| 32 | 
            +
                expect(gitlabButton).toBeInTheDocument();
         | 
| 33 | 
            +
              });
         | 
| 34 | 
            +
             | 
| 35 | 
            +
              it("should redirect to GitHub auth URL when GitHub button is clicked", async () => {
         | 
| 36 | 
            +
                const user = userEvent.setup();
         | 
| 37 | 
            +
                const mockUrl = "https://github.com/login/oauth/authorize";
         | 
| 38 | 
            +
                render(<AuthModal githubAuthUrl={mockUrl} appMode="saas" />);
         | 
| 39 | 
            +
             | 
| 40 | 
            +
                const githubButton = screen.getByRole("button", {
         | 
| 41 | 
            +
                  name: "GITHUB$CONNECT_TO_GITHUB",
         | 
| 42 | 
            +
                });
         | 
| 43 | 
            +
                await user.click(githubButton);
         | 
| 44 | 
            +
             | 
| 45 | 
            +
                expect(window.location.href).toBe(mockUrl);
         | 
| 46 | 
            +
              });
         | 
| 47 | 
            +
            });
         | 
    	
        frontend/__tests__/components/features/chat/path-component.test.tsx
    ADDED
    
    | @@ -0,0 +1,34 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { describe, expect, it } from "vitest";
         | 
| 2 | 
            +
            import { isLikelyDirectory } from "#/components/features/chat/path-component";
         | 
| 3 | 
            +
             | 
| 4 | 
            +
            describe("isLikelyDirectory", () => {
         | 
| 5 | 
            +
              it("should return false for empty path", () => {
         | 
| 6 | 
            +
                expect(isLikelyDirectory("")).toBe(false);
         | 
| 7 | 
            +
              });
         | 
| 8 | 
            +
             | 
| 9 | 
            +
              it("should return true for paths ending with forward slash", () => {
         | 
| 10 | 
            +
                expect(isLikelyDirectory("/path/to/dir/")).toBe(true);
         | 
| 11 | 
            +
                expect(isLikelyDirectory("dir/")).toBe(true);
         | 
| 12 | 
            +
              });
         | 
| 13 | 
            +
             | 
| 14 | 
            +
              it("should return true for paths ending with backslash", () => {
         | 
| 15 | 
            +
                expect(isLikelyDirectory("C:\\path\\to\\dir\\")).toBe(true);
         | 
| 16 | 
            +
                expect(isLikelyDirectory("dir\\")).toBe(true);
         | 
| 17 | 
            +
              });
         | 
| 18 | 
            +
             | 
| 19 | 
            +
              it("should return true for paths without extension", () => {
         | 
| 20 | 
            +
                expect(isLikelyDirectory("/path/to/dir")).toBe(true);
         | 
| 21 | 
            +
                expect(isLikelyDirectory("dir")).toBe(true);
         | 
| 22 | 
            +
              });
         | 
| 23 | 
            +
             | 
| 24 | 
            +
              it("should return false for paths ending with dot", () => {
         | 
| 25 | 
            +
                expect(isLikelyDirectory("/path/to/dir.")).toBe(false);
         | 
| 26 | 
            +
                expect(isLikelyDirectory("dir.")).toBe(false);
         | 
| 27 | 
            +
              });
         | 
| 28 | 
            +
             | 
| 29 | 
            +
              it("should return false for paths with file extensions", () => {
         | 
| 30 | 
            +
                expect(isLikelyDirectory("/path/to/file.txt")).toBe(false);
         | 
| 31 | 
            +
                expect(isLikelyDirectory("file.js")).toBe(false);
         | 
| 32 | 
            +
                expect(isLikelyDirectory("script.test.ts")).toBe(false);
         | 
| 33 | 
            +
              });
         | 
| 34 | 
            +
            });
         | 
    	
        frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx
    ADDED
    
    | @@ -0,0 +1,489 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { screen, within } from "@testing-library/react";
         | 
| 2 | 
            +
            import {
         | 
| 3 | 
            +
              afterAll,
         | 
| 4 | 
            +
              afterEach,
         | 
| 5 | 
            +
              beforeAll,
         | 
| 6 | 
            +
              describe,
         | 
| 7 | 
            +
              expect,
         | 
| 8 | 
            +
              it,
         | 
| 9 | 
            +
              test,
         | 
| 10 | 
            +
              vi,
         | 
| 11 | 
            +
            } from "vitest";
         | 
| 12 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 13 | 
            +
            import { renderWithProviders } from "test-utils";
         | 
| 14 | 
            +
            import { formatTimeDelta } from "#/utils/format-time-delta";
         | 
| 15 | 
            +
            import { ConversationCard } from "#/components/features/conversation-panel/conversation-card";
         | 
| 16 | 
            +
            import { clickOnEditButton } from "./utils";
         | 
| 17 | 
            +
             | 
| 18 | 
            +
            // We'll use the actual i18next implementation but override the translation function
         | 
| 19 | 
            +
            import { I18nextProvider } from "react-i18next";
         | 
| 20 | 
            +
            import i18n from "i18next";
         | 
| 21 | 
            +
             | 
| 22 | 
            +
            // Mock the t function to return our custom translations
         | 
| 23 | 
            +
            vi.mock("react-i18next", async () => {
         | 
| 24 | 
            +
              const actual = await vi.importActual("react-i18next");
         | 
| 25 | 
            +
              return {
         | 
| 26 | 
            +
                ...actual,
         | 
| 27 | 
            +
                useTranslation: () => ({
         | 
| 28 | 
            +
                  t: (key: string) => {
         | 
| 29 | 
            +
                    const translations: Record<string, string> = {
         | 
| 30 | 
            +
                      "CONVERSATION$CREATED": "Created",
         | 
| 31 | 
            +
                      "CONVERSATION$AGO": "ago",
         | 
| 32 | 
            +
                      "CONVERSATION$UPDATED": "Updated"
         | 
| 33 | 
            +
                    };
         | 
| 34 | 
            +
                    return translations[key] || key;
         | 
| 35 | 
            +
                  },
         | 
| 36 | 
            +
                  i18n: {
         | 
| 37 | 
            +
                    changeLanguage: () => new Promise(() => {}),
         | 
| 38 | 
            +
                  },
         | 
| 39 | 
            +
                }),
         | 
| 40 | 
            +
              };
         | 
| 41 | 
            +
            });
         | 
| 42 | 
            +
             | 
| 43 | 
            +
            describe("ConversationCard", () => {
         | 
| 44 | 
            +
              const onClick = vi.fn();
         | 
| 45 | 
            +
              const onDelete = vi.fn();
         | 
| 46 | 
            +
              const onChangeTitle = vi.fn();
         | 
| 47 | 
            +
             | 
| 48 | 
            +
              beforeAll(() => {
         | 
| 49 | 
            +
                vi.stubGlobal("window", {
         | 
| 50 | 
            +
                  open: vi.fn(),
         | 
| 51 | 
            +
                  addEventListener: vi.fn(),
         | 
| 52 | 
            +
                  removeEventListener: vi.fn(),
         | 
| 53 | 
            +
                });
         | 
| 54 | 
            +
              });
         | 
| 55 | 
            +
             | 
| 56 | 
            +
              afterEach(() => {
         | 
| 57 | 
            +
                vi.clearAllMocks();
         | 
| 58 | 
            +
              });
         | 
| 59 | 
            +
             | 
| 60 | 
            +
              afterAll(() => {
         | 
| 61 | 
            +
                vi.unstubAllGlobals();
         | 
| 62 | 
            +
              });
         | 
| 63 | 
            +
             | 
| 64 | 
            +
              it("should render the conversation card", () => {
         | 
| 65 | 
            +
                renderWithProviders(
         | 
| 66 | 
            +
                  <ConversationCard
         | 
| 67 | 
            +
                    onDelete={onDelete}
         | 
| 68 | 
            +
                    onChangeTitle={onChangeTitle}
         | 
| 69 | 
            +
                    isActive
         | 
| 70 | 
            +
                    title="Conversation 1"
         | 
| 71 | 
            +
                    selectedRepository={null}
         | 
| 72 | 
            +
                    lastUpdatedAt="2021-10-01T12:00:00Z"
         | 
| 73 | 
            +
                  />,
         | 
| 74 | 
            +
                );
         | 
| 75 | 
            +
             | 
| 76 | 
            +
                const card = screen.getByTestId("conversation-card");
         | 
| 77 | 
            +
             | 
| 78 | 
            +
                within(card).getByText("Conversation 1");
         | 
| 79 | 
            +
             | 
| 80 | 
            +
                // Just check that the card contains the expected text content
         | 
| 81 | 
            +
                expect(card).toHaveTextContent("Created");
         | 
| 82 | 
            +
                expect(card).toHaveTextContent("ago");
         | 
| 83 | 
            +
             | 
| 84 | 
            +
                // Use a regex to match the time part since it might have whitespace
         | 
| 85 | 
            +
                const timeRegex = new RegExp(formatTimeDelta(new Date("2021-10-01T12:00:00Z")));
         | 
| 86 | 
            +
                expect(card).toHaveTextContent(timeRegex);
         | 
| 87 | 
            +
              });
         | 
| 88 | 
            +
             | 
| 89 | 
            +
              it("should render the selectedRepository if available", () => {
         | 
| 90 | 
            +
                const { rerender } = renderWithProviders(
         | 
| 91 | 
            +
                  <ConversationCard
         | 
| 92 | 
            +
                    onDelete={onDelete}
         | 
| 93 | 
            +
                    onChangeTitle={onChangeTitle}
         | 
| 94 | 
            +
                    isActive
         | 
| 95 | 
            +
                    title="Conversation 1"
         | 
| 96 | 
            +
                    selectedRepository={null}
         | 
| 97 | 
            +
                    lastUpdatedAt="2021-10-01T12:00:00Z"
         | 
| 98 | 
            +
                  />,
         | 
| 99 | 
            +
                );
         | 
| 100 | 
            +
             | 
| 101 | 
            +
                expect(
         | 
| 102 | 
            +
                  screen.queryByTestId("conversation-card-selected-repository"),
         | 
| 103 | 
            +
                ).not.toBeInTheDocument();
         | 
| 104 | 
            +
             | 
| 105 | 
            +
                rerender(
         | 
| 106 | 
            +
                  <ConversationCard
         | 
| 107 | 
            +
                    onDelete={onDelete}
         | 
| 108 | 
            +
                    onChangeTitle={onChangeTitle}
         | 
| 109 | 
            +
                    isActive
         | 
| 110 | 
            +
                    title="Conversation 1"
         | 
| 111 | 
            +
                    selectedRepository="org/selectedRepository"
         | 
| 112 | 
            +
                    lastUpdatedAt="2021-10-01T12:00:00Z"
         | 
| 113 | 
            +
                  />,
         | 
| 114 | 
            +
                );
         | 
| 115 | 
            +
             | 
| 116 | 
            +
                screen.getByTestId("conversation-card-selected-repository");
         | 
| 117 | 
            +
              });
         | 
| 118 | 
            +
             | 
| 119 | 
            +
              it("should toggle a context menu when clicking the ellipsis button", async () => {
         | 
| 120 | 
            +
                const user = userEvent.setup();
         | 
| 121 | 
            +
                renderWithProviders(
         | 
| 122 | 
            +
                  <ConversationCard
         | 
| 123 | 
            +
                    onDelete={onDelete}
         | 
| 124 | 
            +
                    onChangeTitle={onChangeTitle}
         | 
| 125 | 
            +
                    isActive
         | 
| 126 | 
            +
                    title="Conversation 1"
         | 
| 127 | 
            +
                    selectedRepository={null}
         | 
| 128 | 
            +
                    lastUpdatedAt="2021-10-01T12:00:00Z"
         | 
| 129 | 
            +
                  />,
         | 
| 130 | 
            +
                );
         | 
| 131 | 
            +
             | 
| 132 | 
            +
                expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
         | 
| 133 | 
            +
             | 
| 134 | 
            +
                const ellipsisButton = screen.getByTestId("ellipsis-button");
         | 
| 135 | 
            +
                await user.click(ellipsisButton);
         | 
| 136 | 
            +
             | 
| 137 | 
            +
                screen.getByTestId("context-menu");
         | 
| 138 | 
            +
             | 
| 139 | 
            +
                await user.click(ellipsisButton);
         | 
| 140 | 
            +
             | 
| 141 | 
            +
                expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
         | 
| 142 | 
            +
              });
         | 
| 143 | 
            +
             | 
| 144 | 
            +
              it("should call onDelete when the delete button is clicked", async () => {
         | 
| 145 | 
            +
                const user = userEvent.setup();
         | 
| 146 | 
            +
                renderWithProviders(
         | 
| 147 | 
            +
                  <ConversationCard
         | 
| 148 | 
            +
                    onDelete={onDelete}
         | 
| 149 | 
            +
                    isActive
         | 
| 150 | 
            +
                    onChangeTitle={onChangeTitle}
         | 
| 151 | 
            +
                    title="Conversation 1"
         | 
| 152 | 
            +
                    selectedRepository={null}
         | 
| 153 | 
            +
                    lastUpdatedAt="2021-10-01T12:00:00Z"
         | 
| 154 | 
            +
                  />,
         | 
| 155 | 
            +
                );
         | 
| 156 | 
            +
             | 
| 157 | 
            +
                const ellipsisButton = screen.getByTestId("ellipsis-button");
         | 
| 158 | 
            +
                await user.click(ellipsisButton);
         | 
| 159 | 
            +
             | 
| 160 | 
            +
                const menu = screen.getByTestId("context-menu");
         | 
| 161 | 
            +
                const deleteButton = within(menu).getByTestId("delete-button");
         | 
| 162 | 
            +
             | 
| 163 | 
            +
                await user.click(deleteButton);
         | 
| 164 | 
            +
             | 
| 165 | 
            +
                expect(onDelete).toHaveBeenCalled();
         | 
| 166 | 
            +
              });
         | 
| 167 | 
            +
             | 
| 168 | 
            +
              test("clicking the selectedRepository should not trigger the onClick handler", async () => {
         | 
| 169 | 
            +
                const user = userEvent.setup();
         | 
| 170 | 
            +
                renderWithProviders(
         | 
| 171 | 
            +
                  <ConversationCard
         | 
| 172 | 
            +
                    onDelete={onDelete}
         | 
| 173 | 
            +
                    isActive
         | 
| 174 | 
            +
                    onChangeTitle={onChangeTitle}
         | 
| 175 | 
            +
                    title="Conversation 1"
         | 
| 176 | 
            +
                    selectedRepository="org/selectedRepository"
         | 
| 177 | 
            +
                    lastUpdatedAt="2021-10-01T12:00:00Z"
         | 
| 178 | 
            +
                  />,
         | 
| 179 | 
            +
                );
         | 
| 180 | 
            +
             | 
| 181 | 
            +
                const selectedRepository = screen.getByTestId(
         | 
| 182 | 
            +
                  "conversation-card-selected-repository",
         | 
| 183 | 
            +
                );
         | 
| 184 | 
            +
                await user.click(selectedRepository);
         | 
| 185 | 
            +
             | 
| 186 | 
            +
                expect(onClick).not.toHaveBeenCalled();
         | 
| 187 | 
            +
              });
         | 
| 188 | 
            +
             | 
| 189 | 
            +
              test("conversation title should call onChangeTitle when changed and blurred", async () => {
         | 
| 190 | 
            +
                const user = userEvent.setup();
         | 
| 191 | 
            +
                renderWithProviders(
         | 
| 192 | 
            +
                  <ConversationCard
         | 
| 193 | 
            +
                    onDelete={onDelete}
         | 
| 194 | 
            +
                    isActive
         | 
| 195 | 
            +
                    title="Conversation 1"
         | 
| 196 | 
            +
                    selectedRepository={null}
         | 
| 197 | 
            +
                    lastUpdatedAt="2021-10-01T12:00:00Z"
         | 
| 198 | 
            +
                    onChangeTitle={onChangeTitle}
         | 
| 199 | 
            +
                  />,
         | 
| 200 | 
            +
                );
         | 
| 201 | 
            +
             | 
| 202 | 
            +
                await clickOnEditButton(user);
         | 
| 203 | 
            +
                const title = screen.getByTestId("conversation-card-title");
         | 
| 204 | 
            +
             | 
| 205 | 
            +
                expect(title).toBeEnabled();
         | 
| 206 | 
            +
                expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
         | 
| 207 | 
            +
                // expect to be focused
         | 
| 208 | 
            +
                expect(document.activeElement).toBe(title);
         | 
| 209 | 
            +
             | 
| 210 | 
            +
                await user.clear(title);
         | 
| 211 | 
            +
                await user.type(title, "New Conversation Name   ");
         | 
| 212 | 
            +
                await user.tab();
         | 
| 213 | 
            +
             | 
| 214 | 
            +
                expect(onChangeTitle).toHaveBeenCalledWith("New Conversation Name");
         | 
| 215 | 
            +
                expect(title).toHaveValue("New Conversation Name");
         | 
| 216 | 
            +
              });
         | 
| 217 | 
            +
             | 
| 218 | 
            +
              it("should reset title and not call onChangeTitle when the title is empty", async () => {
         | 
| 219 | 
            +
                const user = userEvent.setup();
         | 
| 220 | 
            +
                renderWithProviders(
         | 
| 221 | 
            +
                  <ConversationCard
         | 
| 222 | 
            +
                    onDelete={onDelete}
         | 
| 223 | 
            +
                    isActive
         | 
| 224 | 
            +
                    onChangeTitle={onChangeTitle}
         | 
| 225 | 
            +
                    title="Conversation 1"
         | 
| 226 | 
            +
                    selectedRepository={null}
         | 
| 227 | 
            +
                    lastUpdatedAt="2021-10-01T12:00:00Z"
         | 
| 228 | 
            +
                  />,
         | 
| 229 | 
            +
                );
         | 
| 230 | 
            +
             | 
| 231 | 
            +
                await clickOnEditButton(user);
         | 
| 232 | 
            +
             | 
| 233 | 
            +
                const title = screen.getByTestId("conversation-card-title");
         | 
| 234 | 
            +
             | 
| 235 | 
            +
                await user.clear(title);
         | 
| 236 | 
            +
                await user.tab();
         | 
| 237 | 
            +
             | 
| 238 | 
            +
                expect(onChangeTitle).not.toHaveBeenCalled();
         | 
| 239 | 
            +
                expect(title).toHaveValue("Conversation 1");
         | 
| 240 | 
            +
              });
         | 
| 241 | 
            +
             | 
| 242 | 
            +
              test("clicking the title should trigger the onClick handler", async () => {
         | 
| 243 | 
            +
                const user = userEvent.setup();
         | 
| 244 | 
            +
                renderWithProviders(
         | 
| 245 | 
            +
                  <ConversationCard
         | 
| 246 | 
            +
                    onClick={onClick}
         | 
| 247 | 
            +
                    onDelete={onDelete}
         | 
| 248 | 
            +
                    isActive
         | 
| 249 | 
            +
                    onChangeTitle={onChangeTitle}
         | 
| 250 | 
            +
                    title="Conversation 1"
         | 
| 251 | 
            +
                    selectedRepository={null}
         | 
| 252 | 
            +
                    lastUpdatedAt="2021-10-01T12:00:00Z"
         | 
| 253 | 
            +
                  />,
         | 
| 254 | 
            +
                );
         | 
| 255 | 
            +
             | 
| 256 | 
            +
                const title = screen.getByTestId("conversation-card-title");
         | 
| 257 | 
            +
                await user.click(title);
         | 
| 258 | 
            +
             | 
| 259 | 
            +
                expect(onClick).toHaveBeenCalled();
         | 
| 260 | 
            +
              });
         | 
| 261 | 
            +
             | 
| 262 | 
            +
              test("clicking the title should not trigger the onClick handler if edit mode", async () => {
         | 
| 263 | 
            +
                const user = userEvent.setup();
         | 
| 264 | 
            +
                renderWithProviders(
         | 
| 265 | 
            +
                  <ConversationCard
         | 
| 266 | 
            +
                    onDelete={onDelete}
         | 
| 267 | 
            +
                    isActive
         | 
| 268 | 
            +
                    onChangeTitle={onChangeTitle}
         | 
| 269 | 
            +
                    title="Conversation 1"
         | 
| 270 | 
            +
                    selectedRepository={null}
         | 
| 271 | 
            +
                    lastUpdatedAt="2021-10-01T12:00:00Z"
         | 
| 272 | 
            +
                  />,
         | 
| 273 | 
            +
                );
         | 
| 274 | 
            +
             | 
| 275 | 
            +
                await clickOnEditButton(user);
         | 
| 276 | 
            +
             | 
| 277 | 
            +
                const title = screen.getByTestId("conversation-card-title");
         | 
| 278 | 
            +
                await user.click(title);
         | 
| 279 | 
            +
             | 
| 280 | 
            +
                expect(onClick).not.toHaveBeenCalled();
         | 
| 281 | 
            +
              });
         | 
| 282 | 
            +
             | 
| 283 | 
            +
              test("clicking the delete button should not trigger the onClick handler", async () => {
         | 
| 284 | 
            +
                const user = userEvent.setup();
         | 
| 285 | 
            +
                renderWithProviders(
         | 
| 286 | 
            +
                  <ConversationCard
         | 
| 287 | 
            +
                    onDelete={onDelete}
         | 
| 288 | 
            +
                    isActive
         | 
| 289 | 
            +
                    onChangeTitle={onChangeTitle}
         | 
| 290 | 
            +
                    title="Conversation 1"
         | 
| 291 | 
            +
                    selectedRepository={null}
         | 
| 292 | 
            +
                    lastUpdatedAt="2021-10-01T12:00:00Z"
         | 
| 293 | 
            +
                  />,
         | 
| 294 | 
            +
                );
         | 
| 295 | 
            +
             | 
| 296 | 
            +
                const ellipsisButton = screen.getByTestId("ellipsis-button");
         | 
| 297 | 
            +
                await user.click(ellipsisButton);
         | 
| 298 | 
            +
             | 
| 299 | 
            +
                const menu = screen.getByTestId("context-menu");
         | 
| 300 | 
            +
                const deleteButton = within(menu).getByTestId("delete-button");
         | 
| 301 | 
            +
             | 
| 302 | 
            +
                await user.click(deleteButton);
         | 
| 303 | 
            +
             | 
| 304 | 
            +
                expect(onClick).not.toHaveBeenCalled();
         | 
| 305 | 
            +
              });
         | 
| 306 | 
            +
             | 
| 307 | 
            +
              it("should show display cost button only when showOptions is true", async () => {
         | 
| 308 | 
            +
                const user = userEvent.setup();
         | 
| 309 | 
            +
                const { rerender } = renderWithProviders(
         | 
| 310 | 
            +
                  <ConversationCard
         | 
| 311 | 
            +
                    onDelete={onDelete}
         | 
| 312 | 
            +
                    onChangeTitle={onChangeTitle}
         | 
| 313 | 
            +
                    isActive
         | 
| 314 | 
            +
                    title="Conversation 1"
         | 
| 315 | 
            +
                    selectedRepository={null}
         | 
| 316 | 
            +
                    lastUpdatedAt="2021-10-01T12:00:00Z"
         | 
| 317 | 
            +
                  />,
         | 
| 318 | 
            +
                );
         | 
| 319 | 
            +
             | 
| 320 | 
            +
                const ellipsisButton = screen.getByTestId("ellipsis-button");
         | 
| 321 | 
            +
                await user.click(ellipsisButton);
         | 
| 322 | 
            +
             | 
| 323 | 
            +
                // Wait for context menu to appear
         | 
| 324 | 
            +
                const menu = await screen.findByTestId("context-menu");
         | 
| 325 | 
            +
                expect(
         | 
| 326 | 
            +
                  within(menu).queryByTestId("display-cost-button"),
         | 
| 327 | 
            +
                ).not.toBeInTheDocument();
         | 
| 328 | 
            +
             | 
| 329 | 
            +
                // Close menu
         | 
| 330 | 
            +
                await user.click(ellipsisButton);
         | 
| 331 | 
            +
             | 
| 332 | 
            +
                rerender(
         | 
| 333 | 
            +
                  <ConversationCard
         | 
| 334 | 
            +
                    onDelete={onDelete}
         | 
| 335 | 
            +
                    onChangeTitle={onChangeTitle}
         | 
| 336 | 
            +
                    showOptions
         | 
| 337 | 
            +
                    isActive
         | 
| 338 | 
            +
                    title="Conversation 1"
         | 
| 339 | 
            +
                    selectedRepository={null}
         | 
| 340 | 
            +
                    lastUpdatedAt="2021-10-01T12:00:00Z"
         | 
| 341 | 
            +
                  />,
         | 
| 342 | 
            +
                );
         | 
| 343 | 
            +
             | 
| 344 | 
            +
                // Open menu again
         | 
| 345 | 
            +
                await user.click(ellipsisButton);
         | 
| 346 | 
            +
             | 
| 347 | 
            +
                // Wait for context menu to appear and check for display cost button
         | 
| 348 | 
            +
                const newMenu = await screen.findByTestId("context-menu");
         | 
| 349 | 
            +
                within(newMenu).getByTestId("display-cost-button");
         | 
| 350 | 
            +
              });
         | 
| 351 | 
            +
             | 
| 352 | 
            +
              it("should show metrics modal when clicking the display cost button", async () => {
         | 
| 353 | 
            +
                const user = userEvent.setup();
         | 
| 354 | 
            +
                renderWithProviders(
         | 
| 355 | 
            +
                  <ConversationCard
         | 
| 356 | 
            +
                    onDelete={onDelete}
         | 
| 357 | 
            +
                    isActive
         | 
| 358 | 
            +
                    onChangeTitle={onChangeTitle}
         | 
| 359 | 
            +
                    title="Conversation 1"
         | 
| 360 | 
            +
                    selectedRepository={null}
         | 
| 361 | 
            +
                    lastUpdatedAt="2021-10-01T12:00:00Z"
         | 
| 362 | 
            +
                    showOptions
         | 
| 363 | 
            +
                  />,
         | 
| 364 | 
            +
                );
         | 
| 365 | 
            +
             | 
| 366 | 
            +
                const ellipsisButton = screen.getByTestId("ellipsis-button");
         | 
| 367 | 
            +
                await user.click(ellipsisButton);
         | 
| 368 | 
            +
             | 
| 369 | 
            +
                const menu = screen.getByTestId("context-menu");
         | 
| 370 | 
            +
                const displayCostButton = within(menu).getByTestId("display-cost-button");
         | 
| 371 | 
            +
             | 
| 372 | 
            +
                await user.click(displayCostButton);
         | 
| 373 | 
            +
             | 
| 374 | 
            +
                // Verify if metrics modal is displayed by checking for the modal content
         | 
| 375 | 
            +
                expect(screen.getByTestId("metrics-modal")).toBeInTheDocument();
         | 
| 376 | 
            +
              });
         | 
| 377 | 
            +
             | 
| 378 | 
            +
              it("should not display the edit or delete options if the handler is not provided", async () => {
         | 
| 379 | 
            +
                const user = userEvent.setup();
         | 
| 380 | 
            +
                const { rerender } = renderWithProviders(
         | 
| 381 | 
            +
                  <ConversationCard
         | 
| 382 | 
            +
                    onClick={onClick}
         | 
| 383 | 
            +
                    onChangeTitle={onChangeTitle}
         | 
| 384 | 
            +
                    title="Conversation 1"
         | 
| 385 | 
            +
                    selectedRepository={null}
         | 
| 386 | 
            +
                    lastUpdatedAt="2021-10-01T12:00:00Z"
         | 
| 387 | 
            +
                  />,
         | 
| 388 | 
            +
                );
         | 
| 389 | 
            +
             | 
| 390 | 
            +
                const ellipsisButton = screen.getByTestId("ellipsis-button");
         | 
| 391 | 
            +
                await user.click(ellipsisButton);
         | 
| 392 | 
            +
             | 
| 393 | 
            +
                const menu = await screen.findByTestId("context-menu");
         | 
| 394 | 
            +
                expect(within(menu).queryByTestId("edit-button")).toBeInTheDocument();
         | 
| 395 | 
            +
                expect(within(menu).queryByTestId("delete-button")).not.toBeInTheDocument();
         | 
| 396 | 
            +
             | 
| 397 | 
            +
                // toggle to hide the context menu
         | 
| 398 | 
            +
                await user.click(ellipsisButton);
         | 
| 399 | 
            +
             | 
| 400 | 
            +
                rerender(
         | 
| 401 | 
            +
                  <ConversationCard
         | 
| 402 | 
            +
                    onClick={onClick}
         | 
| 403 | 
            +
                    onDelete={onDelete}
         | 
| 404 | 
            +
                    title="Conversation 1"
         | 
| 405 | 
            +
                    selectedRepository={null}
         | 
| 406 | 
            +
                    lastUpdatedAt="2021-10-01T12:00:00Z"
         | 
| 407 | 
            +
                  />,
         | 
| 408 | 
            +
                );
         | 
| 409 | 
            +
             | 
| 410 | 
            +
                await user.click(ellipsisButton);
         | 
| 411 | 
            +
                const newMenu = await screen.findByTestId("context-menu");
         | 
| 412 | 
            +
                expect(
         | 
| 413 | 
            +
                  within(newMenu).queryByTestId("edit-button"),
         | 
| 414 | 
            +
                ).not.toBeInTheDocument();
         | 
| 415 | 
            +
                expect(within(newMenu).queryByTestId("delete-button")).toBeInTheDocument();
         | 
| 416 | 
            +
              });
         | 
| 417 | 
            +
             | 
| 418 | 
            +
              it("should not render the ellipsis button if there are no actions", () => {
         | 
| 419 | 
            +
                const { rerender } = renderWithProviders(
         | 
| 420 | 
            +
                  <ConversationCard
         | 
| 421 | 
            +
                    onClick={onClick}
         | 
| 422 | 
            +
                    onDelete={onDelete}
         | 
| 423 | 
            +
                    onChangeTitle={onChangeTitle}
         | 
| 424 | 
            +
                    title="Conversation 1"
         | 
| 425 | 
            +
                    selectedRepository={null}
         | 
| 426 | 
            +
                    lastUpdatedAt="2021-10-01T12:00:00Z"
         | 
| 427 | 
            +
                  />,
         | 
| 428 | 
            +
                );
         | 
| 429 | 
            +
             | 
| 430 | 
            +
                expect(screen.getByTestId("ellipsis-button")).toBeInTheDocument();
         | 
| 431 | 
            +
             | 
| 432 | 
            +
                rerender(
         | 
| 433 | 
            +
                  <ConversationCard
         | 
| 434 | 
            +
                    onClick={onClick}
         | 
| 435 | 
            +
                    onDelete={onDelete}
         | 
| 436 | 
            +
                    title="Conversation 1"
         | 
| 437 | 
            +
                    selectedRepository={null}
         | 
| 438 | 
            +
                    lastUpdatedAt="2021-10-01T12:00:00Z"
         | 
| 439 | 
            +
                  />,
         | 
| 440 | 
            +
                );
         | 
| 441 | 
            +
             | 
| 442 | 
            +
                expect(screen.getByTestId("ellipsis-button")).toBeInTheDocument();
         | 
| 443 | 
            +
             | 
| 444 | 
            +
                rerender(
         | 
| 445 | 
            +
                  <ConversationCard
         | 
| 446 | 
            +
                    onClick={onClick}
         | 
| 447 | 
            +
                    title="Conversation 1"
         | 
| 448 | 
            +
                    selectedRepository={null}
         | 
| 449 | 
            +
                    lastUpdatedAt="2021-10-01T12:00:00Z"
         | 
| 450 | 
            +
                  />,
         | 
| 451 | 
            +
                );
         | 
| 452 | 
            +
             | 
| 453 | 
            +
                expect(screen.queryByTestId("ellipsis-button")).not.toBeInTheDocument();
         | 
| 454 | 
            +
              });
         | 
| 455 | 
            +
             | 
| 456 | 
            +
              describe("state indicator", () => {
         | 
| 457 | 
            +
                it("should render the 'STOPPED' indicator by default", () => {
         | 
| 458 | 
            +
                  renderWithProviders(
         | 
| 459 | 
            +
                    <ConversationCard
         | 
| 460 | 
            +
                      onDelete={onDelete}
         | 
| 461 | 
            +
                      isActive
         | 
| 462 | 
            +
                      onChangeTitle={onChangeTitle}
         | 
| 463 | 
            +
                      title="Conversation 1"
         | 
| 464 | 
            +
                      selectedRepository={null}
         | 
| 465 | 
            +
                      lastUpdatedAt="2021-10-01T12:00:00Z"
         | 
| 466 | 
            +
                    />,
         | 
| 467 | 
            +
                  );
         | 
| 468 | 
            +
             | 
| 469 | 
            +
                  screen.getByTestId("STOPPED-indicator");
         | 
| 470 | 
            +
                });
         | 
| 471 | 
            +
             | 
| 472 | 
            +
                it("should render the other indicators when provided", () => {
         | 
| 473 | 
            +
                  renderWithProviders(
         | 
| 474 | 
            +
                    <ConversationCard
         | 
| 475 | 
            +
                      onDelete={onDelete}
         | 
| 476 | 
            +
                      isActive
         | 
| 477 | 
            +
                      onChangeTitle={onChangeTitle}
         | 
| 478 | 
            +
                      title="Conversation 1"
         | 
| 479 | 
            +
                      selectedRepository={null}
         | 
| 480 | 
            +
                      lastUpdatedAt="2021-10-01T12:00:00Z"
         | 
| 481 | 
            +
                      status="RUNNING"
         | 
| 482 | 
            +
                    />,
         | 
| 483 | 
            +
                  );
         | 
| 484 | 
            +
             | 
| 485 | 
            +
                  expect(screen.queryByTestId("STOPPED-indicator")).not.toBeInTheDocument();
         | 
| 486 | 
            +
                  screen.getByTestId("RUNNING-indicator");
         | 
| 487 | 
            +
                });
         | 
| 488 | 
            +
              });
         | 
| 489 | 
            +
            });
         | 
    	
        frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx
    ADDED
    
    | @@ -0,0 +1,290 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { screen, waitFor, within } from "@testing-library/react";
         | 
| 2 | 
            +
            import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
         | 
| 3 | 
            +
            import { QueryClientConfig } from "@tanstack/react-query";
         | 
| 4 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 5 | 
            +
            import { createRoutesStub } from "react-router";
         | 
| 6 | 
            +
            import React from "react";
         | 
| 7 | 
            +
            import { renderWithProviders } from "test-utils";
         | 
| 8 | 
            +
            import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel";
         | 
| 9 | 
            +
            import OpenHands from "#/api/open-hands";
         | 
| 10 | 
            +
            import { Conversation } from "#/api/open-hands.types";
         | 
| 11 | 
            +
             | 
| 12 | 
            +
            describe("ConversationPanel", () => {
         | 
| 13 | 
            +
              const onCloseMock = vi.fn();
         | 
| 14 | 
            +
              const RouterStub = createRoutesStub([
         | 
| 15 | 
            +
                {
         | 
| 16 | 
            +
                  Component: () => <ConversationPanel onClose={onCloseMock} />,
         | 
| 17 | 
            +
                  path: "/",
         | 
| 18 | 
            +
                },
         | 
| 19 | 
            +
              ]);
         | 
| 20 | 
            +
             | 
| 21 | 
            +
              const renderConversationPanel = (config?: QueryClientConfig) =>
         | 
| 22 | 
            +
                renderWithProviders(<RouterStub />, {
         | 
| 23 | 
            +
                  preloadedState: {
         | 
| 24 | 
            +
                    metrics: {
         | 
| 25 | 
            +
                      cost: null,
         | 
| 26 | 
            +
                      usage: null,
         | 
| 27 | 
            +
                    },
         | 
| 28 | 
            +
                  },
         | 
| 29 | 
            +
                });
         | 
| 30 | 
            +
             | 
| 31 | 
            +
              beforeAll(() => {
         | 
| 32 | 
            +
                vi.mock("react-router", async (importOriginal) => ({
         | 
| 33 | 
            +
                  ...(await importOriginal<typeof import("react-router")>()),
         | 
| 34 | 
            +
                  Link: ({ children }: React.PropsWithChildren) => children,
         | 
| 35 | 
            +
                  useNavigate: vi.fn(() => vi.fn()),
         | 
| 36 | 
            +
                  useLocation: vi.fn(() => ({ pathname: "/conversation" })),
         | 
| 37 | 
            +
                  useParams: vi.fn(() => ({ conversationId: "2" })),
         | 
| 38 | 
            +
                }));
         | 
| 39 | 
            +
              });
         | 
| 40 | 
            +
             | 
| 41 | 
            +
              const mockConversations: Conversation[] = [
         | 
| 42 | 
            +
                {
         | 
| 43 | 
            +
                  conversation_id: "1",
         | 
| 44 | 
            +
                  title: "Conversation 1",
         | 
| 45 | 
            +
                  selected_repository: null,
         | 
| 46 | 
            +
                  git_provider: null,
         | 
| 47 | 
            +
                  selected_branch: null,
         | 
| 48 | 
            +
                  last_updated_at: "2021-10-01T12:00:00Z",
         | 
| 49 | 
            +
                  created_at: "2021-10-01T12:00:00Z",
         | 
| 50 | 
            +
                  status: "STOPPED" as const,
         | 
| 51 | 
            +
                  url: null,
         | 
| 52 | 
            +
                  session_api_key: null,
         | 
| 53 | 
            +
                },
         | 
| 54 | 
            +
                {
         | 
| 55 | 
            +
                  conversation_id: "2",
         | 
| 56 | 
            +
                  title: "Conversation 2",
         | 
| 57 | 
            +
                  selected_repository: null,
         | 
| 58 | 
            +
                  git_provider: null,
         | 
| 59 | 
            +
                  selected_branch: null,
         | 
| 60 | 
            +
                  last_updated_at: "2021-10-02T12:00:00Z",
         | 
| 61 | 
            +
                  created_at: "2021-10-02T12:00:00Z",
         | 
| 62 | 
            +
                  status: "STOPPED" as const,
         | 
| 63 | 
            +
                  url: null,
         | 
| 64 | 
            +
                  session_api_key: null,
         | 
| 65 | 
            +
                },
         | 
| 66 | 
            +
                {
         | 
| 67 | 
            +
                  conversation_id: "3",
         | 
| 68 | 
            +
                  title: "Conversation 3",
         | 
| 69 | 
            +
                  selected_repository: null,
         | 
| 70 | 
            +
                  git_provider: null,
         | 
| 71 | 
            +
                  selected_branch: null,
         | 
| 72 | 
            +
                  last_updated_at: "2021-10-03T12:00:00Z",
         | 
| 73 | 
            +
                  created_at: "2021-10-03T12:00:00Z",
         | 
| 74 | 
            +
                  status: "STOPPED" as const,
         | 
| 75 | 
            +
                  url: null,
         | 
| 76 | 
            +
                  session_api_key: null,
         | 
| 77 | 
            +
                },
         | 
| 78 | 
            +
              ];
         | 
| 79 | 
            +
             | 
| 80 | 
            +
              beforeEach(() => {
         | 
| 81 | 
            +
                vi.clearAllMocks();
         | 
| 82 | 
            +
                vi.restoreAllMocks();
         | 
| 83 | 
            +
                // Setup default mock for getUserConversations
         | 
| 84 | 
            +
                vi.spyOn(OpenHands, "getUserConversations").mockResolvedValue([
         | 
| 85 | 
            +
                  ...mockConversations,
         | 
| 86 | 
            +
                ]);
         | 
| 87 | 
            +
              });
         | 
| 88 | 
            +
             | 
| 89 | 
            +
              it("should render the conversations", async () => {
         | 
| 90 | 
            +
                renderConversationPanel();
         | 
| 91 | 
            +
                const cards = await screen.findAllByTestId("conversation-card");
         | 
| 92 | 
            +
             | 
| 93 | 
            +
                // NOTE that we filter out conversations that don't have a created_at property
         | 
| 94 | 
            +
                // (mock data has 4 conversations, but only 3 have a created_at property)
         | 
| 95 | 
            +
                expect(cards).toHaveLength(3);
         | 
| 96 | 
            +
              });
         | 
| 97 | 
            +
             | 
| 98 | 
            +
              it("should display an empty state when there are no conversations", async () => {
         | 
| 99 | 
            +
                const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
         | 
| 100 | 
            +
                getUserConversationsSpy.mockResolvedValue([]);
         | 
| 101 | 
            +
             | 
| 102 | 
            +
                renderConversationPanel();
         | 
| 103 | 
            +
             | 
| 104 | 
            +
                const emptyState = await screen.findByText("CONVERSATION$NO_CONVERSATIONS");
         | 
| 105 | 
            +
                expect(emptyState).toBeInTheDocument();
         | 
| 106 | 
            +
              });
         | 
| 107 | 
            +
             | 
| 108 | 
            +
              it("should handle an error when fetching conversations", async () => {
         | 
| 109 | 
            +
                const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
         | 
| 110 | 
            +
                getUserConversationsSpy.mockRejectedValue(
         | 
| 111 | 
            +
                  new Error("Failed to fetch conversations"),
         | 
| 112 | 
            +
                );
         | 
| 113 | 
            +
             | 
| 114 | 
            +
                renderConversationPanel();
         | 
| 115 | 
            +
             | 
| 116 | 
            +
                const error = await screen.findByText("Failed to fetch conversations");
         | 
| 117 | 
            +
                expect(error).toBeInTheDocument();
         | 
| 118 | 
            +
              });
         | 
| 119 | 
            +
             | 
| 120 | 
            +
              it("should cancel deleting a conversation", async () => {
         | 
| 121 | 
            +
                const user = userEvent.setup();
         | 
| 122 | 
            +
                renderConversationPanel();
         | 
| 123 | 
            +
             | 
| 124 | 
            +
                let cards = await screen.findAllByTestId("conversation-card");
         | 
| 125 | 
            +
                expect(
         | 
| 126 | 
            +
                  within(cards[0]).queryByTestId("delete-button"),
         | 
| 127 | 
            +
                ).not.toBeInTheDocument();
         | 
| 128 | 
            +
             | 
| 129 | 
            +
                const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
         | 
| 130 | 
            +
                await user.click(ellipsisButton);
         | 
| 131 | 
            +
                const deleteButton = screen.getByTestId("delete-button");
         | 
| 132 | 
            +
             | 
| 133 | 
            +
                // Click the first delete button
         | 
| 134 | 
            +
                await user.click(deleteButton);
         | 
| 135 | 
            +
             | 
| 136 | 
            +
                // Cancel the deletion
         | 
| 137 | 
            +
                const cancelButton = screen.getByRole("button", { name: /cancel/i });
         | 
| 138 | 
            +
                await user.click(cancelButton);
         | 
| 139 | 
            +
             | 
| 140 | 
            +
                expect(
         | 
| 141 | 
            +
                  screen.queryByRole("button", { name: /cancel/i }),
         | 
| 142 | 
            +
                ).not.toBeInTheDocument();
         | 
| 143 | 
            +
             | 
| 144 | 
            +
                // Ensure the conversation is not deleted
         | 
| 145 | 
            +
                cards = await screen.findAllByTestId("conversation-card");
         | 
| 146 | 
            +
                expect(cards).toHaveLength(3);
         | 
| 147 | 
            +
              });
         | 
| 148 | 
            +
             | 
| 149 | 
            +
              it("should delete a conversation", async () => {
         | 
| 150 | 
            +
                const user = userEvent.setup();
         | 
| 151 | 
            +
                const mockData: Conversation[] = [
         | 
| 152 | 
            +
                  {
         | 
| 153 | 
            +
                    conversation_id: "1",
         | 
| 154 | 
            +
                    title: "Conversation 1",
         | 
| 155 | 
            +
                    selected_repository: null,
         | 
| 156 | 
            +
                    git_provider: null,
         | 
| 157 | 
            +
                    selected_branch: null,
         | 
| 158 | 
            +
                    last_updated_at: "2021-10-01T12:00:00Z",
         | 
| 159 | 
            +
                    created_at: "2021-10-01T12:00:00Z",
         | 
| 160 | 
            +
                    status: "STOPPED" as const,
         | 
| 161 | 
            +
                    url: null,
         | 
| 162 | 
            +
                    session_api_key: null,
         | 
| 163 | 
            +
                  },
         | 
| 164 | 
            +
                  {
         | 
| 165 | 
            +
                    conversation_id: "2",
         | 
| 166 | 
            +
                    title: "Conversation 2",
         | 
| 167 | 
            +
                    selected_repository: null,
         | 
| 168 | 
            +
                    git_provider: null,
         | 
| 169 | 
            +
                    selected_branch: null,
         | 
| 170 | 
            +
                    last_updated_at: "2021-10-02T12:00:00Z",
         | 
| 171 | 
            +
                    created_at: "2021-10-02T12:00:00Z",
         | 
| 172 | 
            +
                    status: "STOPPED" as const,
         | 
| 173 | 
            +
                    url: null,
         | 
| 174 | 
            +
                    session_api_key: null,
         | 
| 175 | 
            +
                  },
         | 
| 176 | 
            +
                  {
         | 
| 177 | 
            +
                    conversation_id: "3",
         | 
| 178 | 
            +
                    title: "Conversation 3",
         | 
| 179 | 
            +
                    selected_repository: null,
         | 
| 180 | 
            +
                    git_provider: null,
         | 
| 181 | 
            +
                    selected_branch: null,
         | 
| 182 | 
            +
                    last_updated_at: "2021-10-03T12:00:00Z",
         | 
| 183 | 
            +
                    created_at: "2021-10-03T12:00:00Z",
         | 
| 184 | 
            +
                    status: "STOPPED" as const,
         | 
| 185 | 
            +
                    url: null,
         | 
| 186 | 
            +
                    session_api_key: null,
         | 
| 187 | 
            +
                  },
         | 
| 188 | 
            +
                ];
         | 
| 189 | 
            +
             | 
| 190 | 
            +
                const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
         | 
| 191 | 
            +
                getUserConversationsSpy.mockImplementation(async () => mockData);
         | 
| 192 | 
            +
             | 
| 193 | 
            +
                const deleteUserConversationSpy = vi.spyOn(
         | 
| 194 | 
            +
                  OpenHands,
         | 
| 195 | 
            +
                  "deleteUserConversation",
         | 
| 196 | 
            +
                );
         | 
| 197 | 
            +
                deleteUserConversationSpy.mockImplementation(async (id: string) => {
         | 
| 198 | 
            +
                  const index = mockData.findIndex((conv) => conv.conversation_id === id);
         | 
| 199 | 
            +
                  if (index !== -1) {
         | 
| 200 | 
            +
                    mockData.splice(index, 1);
         | 
| 201 | 
            +
                  }
         | 
| 202 | 
            +
                });
         | 
| 203 | 
            +
             | 
| 204 | 
            +
                renderConversationPanel();
         | 
| 205 | 
            +
             | 
| 206 | 
            +
                const cards = await screen.findAllByTestId("conversation-card");
         | 
| 207 | 
            +
                expect(cards).toHaveLength(3);
         | 
| 208 | 
            +
             | 
| 209 | 
            +
                const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
         | 
| 210 | 
            +
                await user.click(ellipsisButton);
         | 
| 211 | 
            +
                const deleteButton = screen.getByTestId("delete-button");
         | 
| 212 | 
            +
             | 
| 213 | 
            +
                // Click the first delete button
         | 
| 214 | 
            +
                await user.click(deleteButton);
         | 
| 215 | 
            +
             | 
| 216 | 
            +
                // Confirm the deletion
         | 
| 217 | 
            +
                const confirmButton = screen.getByRole("button", { name: /confirm/i });
         | 
| 218 | 
            +
                await user.click(confirmButton);
         | 
| 219 | 
            +
             | 
| 220 | 
            +
                expect(
         | 
| 221 | 
            +
                  screen.queryByRole("button", { name: /confirm/i }),
         | 
| 222 | 
            +
                ).not.toBeInTheDocument();
         | 
| 223 | 
            +
             | 
| 224 | 
            +
                // Wait for the cards to update
         | 
| 225 | 
            +
                await waitFor(() => {
         | 
| 226 | 
            +
                  const updatedCards = screen.getAllByTestId("conversation-card");
         | 
| 227 | 
            +
                  expect(updatedCards).toHaveLength(2);
         | 
| 228 | 
            +
                });
         | 
| 229 | 
            +
              });
         | 
| 230 | 
            +
             | 
| 231 | 
            +
              it("should call onClose after clicking a card", async () => {
         | 
| 232 | 
            +
                const user = userEvent.setup();
         | 
| 233 | 
            +
                renderConversationPanel();
         | 
| 234 | 
            +
                const cards = await screen.findAllByTestId("conversation-card");
         | 
| 235 | 
            +
                const firstCard = cards[1];
         | 
| 236 | 
            +
             | 
| 237 | 
            +
                await user.click(firstCard);
         | 
| 238 | 
            +
             | 
| 239 | 
            +
                expect(onCloseMock).toHaveBeenCalledOnce();
         | 
| 240 | 
            +
              });
         | 
| 241 | 
            +
             | 
| 242 | 
            +
              it("should refetch data on rerenders", async () => {
         | 
| 243 | 
            +
                const user = userEvent.setup();
         | 
| 244 | 
            +
                const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
         | 
| 245 | 
            +
                getUserConversationsSpy.mockResolvedValue([...mockConversations]);
         | 
| 246 | 
            +
             | 
| 247 | 
            +
                function PanelWithToggle() {
         | 
| 248 | 
            +
                  const [isOpen, setIsOpen] = React.useState(true);
         | 
| 249 | 
            +
                  return (
         | 
| 250 | 
            +
                    <>
         | 
| 251 | 
            +
                      <button type="button" onClick={() => setIsOpen((prev) => !prev)}>
         | 
| 252 | 
            +
                        Toggle
         | 
| 253 | 
            +
                      </button>
         | 
| 254 | 
            +
                      {isOpen && <ConversationPanel onClose={onCloseMock} />}
         | 
| 255 | 
            +
                    </>
         | 
| 256 | 
            +
                  );
         | 
| 257 | 
            +
                }
         | 
| 258 | 
            +
             | 
| 259 | 
            +
                const MyRouterStub = createRoutesStub([
         | 
| 260 | 
            +
                  {
         | 
| 261 | 
            +
                    Component: PanelWithToggle,
         | 
| 262 | 
            +
                    path: "/",
         | 
| 263 | 
            +
                  },
         | 
| 264 | 
            +
                ]);
         | 
| 265 | 
            +
             | 
| 266 | 
            +
                renderWithProviders(<MyRouterStub />, {
         | 
| 267 | 
            +
                  preloadedState: {
         | 
| 268 | 
            +
                    metrics: {
         | 
| 269 | 
            +
                      cost: null,
         | 
| 270 | 
            +
                      usage: null,
         | 
| 271 | 
            +
                    },
         | 
| 272 | 
            +
                  },
         | 
| 273 | 
            +
                });
         | 
| 274 | 
            +
             | 
| 275 | 
            +
                const toggleButton = screen.getByText("Toggle");
         | 
| 276 | 
            +
             | 
| 277 | 
            +
                // Initial render
         | 
| 278 | 
            +
                const cards = await screen.findAllByTestId("conversation-card");
         | 
| 279 | 
            +
                expect(cards).toHaveLength(3);
         | 
| 280 | 
            +
             | 
| 281 | 
            +
                // Toggle off
         | 
| 282 | 
            +
                await user.click(toggleButton);
         | 
| 283 | 
            +
                expect(screen.queryByTestId("conversation-card")).not.toBeInTheDocument();
         | 
| 284 | 
            +
             | 
| 285 | 
            +
                // Toggle on
         | 
| 286 | 
            +
                await user.click(toggleButton);
         | 
| 287 | 
            +
                const newCards = await screen.findAllByTestId("conversation-card");
         | 
| 288 | 
            +
                expect(newCards).toHaveLength(3);
         | 
| 289 | 
            +
              });
         | 
| 290 | 
            +
            });
         | 
    	
        frontend/__tests__/components/features/conversation-panel/utils.ts
    ADDED
    
    | @@ -0,0 +1,17 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { screen, within } from "@testing-library/react";
         | 
| 2 | 
            +
            import { UserEvent } from "@testing-library/user-event";
         | 
| 3 | 
            +
             | 
| 4 | 
            +
            export const clickOnEditButton = async (
         | 
| 5 | 
            +
              user: UserEvent,
         | 
| 6 | 
            +
              container?: HTMLElement,
         | 
| 7 | 
            +
            ) => {
         | 
| 8 | 
            +
              const wrapper = container ? within(container) : screen;
         | 
| 9 | 
            +
             | 
| 10 | 
            +
              const ellipsisButton = wrapper.getByTestId("ellipsis-button");
         | 
| 11 | 
            +
              await user.click(ellipsisButton);
         | 
| 12 | 
            +
             | 
| 13 | 
            +
              const menu = wrapper.getByTestId("context-menu");
         | 
| 14 | 
            +
              const editButton = within(menu).getByTestId("edit-button");
         | 
| 15 | 
            +
             | 
| 16 | 
            +
              await user.click(editButton);
         | 
| 17 | 
            +
            };
         | 
    	
        frontend/__tests__/components/features/home/home-header.test.tsx
    ADDED
    
    | @@ -0,0 +1,93 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
         | 
| 2 | 
            +
            import { render, screen } from "@testing-library/react";
         | 
| 3 | 
            +
            import { Provider } from "react-redux";
         | 
| 4 | 
            +
            import { createRoutesStub } from "react-router";
         | 
| 5 | 
            +
            import { setupStore } from "test-utils";
         | 
| 6 | 
            +
            import { describe, expect, it, vi } from "vitest";
         | 
| 7 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 8 | 
            +
            import { HomeHeader } from "#/components/features/home/home-header";
         | 
| 9 | 
            +
            import OpenHands from "#/api/open-hands";
         | 
| 10 | 
            +
             | 
| 11 | 
            +
            // Mock the translation function
         | 
| 12 | 
            +
            vi.mock("react-i18next", async () => {
         | 
| 13 | 
            +
              const actual = await vi.importActual("react-i18next");
         | 
| 14 | 
            +
              return {
         | 
| 15 | 
            +
                ...actual,
         | 
| 16 | 
            +
                useTranslation: () => ({
         | 
| 17 | 
            +
                  t: (key: string) => {
         | 
| 18 | 
            +
                    // Return a mock translation for the test
         | 
| 19 | 
            +
                    const translations: Record<string, string> = {
         | 
| 20 | 
            +
                      "HOME$LETS_START_BUILDING": "Let's start building",
         | 
| 21 | 
            +
                      "HOME$LAUNCH_FROM_SCRATCH": "Launch from Scratch",
         | 
| 22 | 
            +
                      "HOME$LOADING": "Loading...",
         | 
| 23 | 
            +
                      "HOME$OPENHANDS_DESCRIPTION": "OpenHands is an AI software engineer",
         | 
| 24 | 
            +
                      "HOME$NOT_SURE_HOW_TO_START": "Not sure how to start?",
         | 
| 25 | 
            +
                      "HOME$READ_THIS": "Read this"
         | 
| 26 | 
            +
                    };
         | 
| 27 | 
            +
                    return translations[key] || key;
         | 
| 28 | 
            +
                  },
         | 
| 29 | 
            +
                  i18n: { language: "en" },
         | 
| 30 | 
            +
                }),
         | 
| 31 | 
            +
              };
         | 
| 32 | 
            +
            });
         | 
| 33 | 
            +
             | 
| 34 | 
            +
            const renderHomeHeader = () => {
         | 
| 35 | 
            +
              const RouterStub = createRoutesStub([
         | 
| 36 | 
            +
                {
         | 
| 37 | 
            +
                  Component: HomeHeader,
         | 
| 38 | 
            +
                  path: "/",
         | 
| 39 | 
            +
                },
         | 
| 40 | 
            +
                {
         | 
| 41 | 
            +
                  Component: () => <div data-testid="conversation-screen" />,
         | 
| 42 | 
            +
                  path: "/conversations/:conversationId",
         | 
| 43 | 
            +
                },
         | 
| 44 | 
            +
              ]);
         | 
| 45 | 
            +
             | 
| 46 | 
            +
              return render(<RouterStub />, {
         | 
| 47 | 
            +
                wrapper: ({ children }) => (
         | 
| 48 | 
            +
                  <Provider store={setupStore()}>
         | 
| 49 | 
            +
                    <QueryClientProvider client={new QueryClient()}>
         | 
| 50 | 
            +
                      {children}
         | 
| 51 | 
            +
                    </QueryClientProvider>
         | 
| 52 | 
            +
                  </Provider>
         | 
| 53 | 
            +
                ),
         | 
| 54 | 
            +
              });
         | 
| 55 | 
            +
            };
         | 
| 56 | 
            +
             | 
| 57 | 
            +
            describe("HomeHeader", () => {
         | 
| 58 | 
            +
              it("should create an empty conversation and redirect when pressing the launch from scratch button", async () => {
         | 
| 59 | 
            +
                const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
         | 
| 60 | 
            +
             | 
| 61 | 
            +
                renderHomeHeader();
         | 
| 62 | 
            +
             | 
| 63 | 
            +
                const launchButton = screen.getByRole("button", {
         | 
| 64 | 
            +
                  name: /Launch from Scratch/i,
         | 
| 65 | 
            +
                });
         | 
| 66 | 
            +
                await userEvent.click(launchButton);
         | 
| 67 | 
            +
             | 
| 68 | 
            +
                expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
         | 
| 69 | 
            +
                  undefined,
         | 
| 70 | 
            +
                  undefined,
         | 
| 71 | 
            +
                  undefined,
         | 
| 72 | 
            +
                  [],
         | 
| 73 | 
            +
                  undefined,
         | 
| 74 | 
            +
                  undefined,
         | 
| 75 | 
            +
                  undefined,
         | 
| 76 | 
            +
                );
         | 
| 77 | 
            +
             | 
| 78 | 
            +
                // expect to be redirected to /conversations/:conversationId
         | 
| 79 | 
            +
                await screen.findByTestId("conversation-screen");
         | 
| 80 | 
            +
              });
         | 
| 81 | 
            +
             | 
| 82 | 
            +
              it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
         | 
| 83 | 
            +
                renderHomeHeader();
         | 
| 84 | 
            +
             | 
| 85 | 
            +
                const launchButton = screen.getByRole("button", {
         | 
| 86 | 
            +
                  name: /Launch from Scratch/i,
         | 
| 87 | 
            +
                });
         | 
| 88 | 
            +
                await userEvent.click(launchButton);
         | 
| 89 | 
            +
             | 
| 90 | 
            +
                expect(launchButton).toHaveTextContent(/Loading.../i);
         | 
| 91 | 
            +
                expect(launchButton).toBeDisabled();
         | 
| 92 | 
            +
              });
         | 
| 93 | 
            +
            });
         | 
    	
        frontend/__tests__/components/features/home/repo-connector.test.tsx
    ADDED
    
    | @@ -0,0 +1,241 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { render, screen, waitFor, within } from "@testing-library/react";
         | 
| 2 | 
            +
            import { beforeEach, describe, expect, it, vi } from "vitest";
         | 
| 3 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 4 | 
            +
            import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
         | 
| 5 | 
            +
            import { setupStore } from "test-utils";
         | 
| 6 | 
            +
            import { Provider } from "react-redux";
         | 
| 7 | 
            +
            import { createRoutesStub, Outlet } from "react-router";
         | 
| 8 | 
            +
            import OpenHands from "#/api/open-hands";
         | 
| 9 | 
            +
            import { GitRepository } from "#/types/git";
         | 
| 10 | 
            +
            import { RepoConnector } from "#/components/features/home/repo-connector";
         | 
| 11 | 
            +
            import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
         | 
| 12 | 
            +
             | 
| 13 | 
            +
            const renderRepoConnector = () => {
         | 
| 14 | 
            +
              const mockRepoSelection = vi.fn();
         | 
| 15 | 
            +
              const RouterStub = createRoutesStub([
         | 
| 16 | 
            +
                {
         | 
| 17 | 
            +
                  Component: () => <RepoConnector onRepoSelection={mockRepoSelection} />,
         | 
| 18 | 
            +
                  path: "/",
         | 
| 19 | 
            +
                },
         | 
| 20 | 
            +
                {
         | 
| 21 | 
            +
                  Component: () => <div data-testid="conversation-screen" />,
         | 
| 22 | 
            +
                  path: "/conversations/:conversationId",
         | 
| 23 | 
            +
                },
         | 
| 24 | 
            +
                {
         | 
| 25 | 
            +
                  Component: () => <Outlet />,
         | 
| 26 | 
            +
                  path: "/settings",
         | 
| 27 | 
            +
                  children: [
         | 
| 28 | 
            +
                    {
         | 
| 29 | 
            +
                      Component: () => <div data-testid="settings-screen" />,
         | 
| 30 | 
            +
                      path: "/settings",
         | 
| 31 | 
            +
                    },
         | 
| 32 | 
            +
                    {
         | 
| 33 | 
            +
                      Component: () => <div data-testid="git-settings-screen" />,
         | 
| 34 | 
            +
                      path: "/settings/git",
         | 
| 35 | 
            +
                    },
         | 
| 36 | 
            +
                  ],
         | 
| 37 | 
            +
                },
         | 
| 38 | 
            +
              ]);
         | 
| 39 | 
            +
             | 
| 40 | 
            +
              return render(<RouterStub />, {
         | 
| 41 | 
            +
                wrapper: ({ children }) => (
         | 
| 42 | 
            +
                  <Provider store={setupStore()}>
         | 
| 43 | 
            +
                    <QueryClientProvider client={new QueryClient()}>
         | 
| 44 | 
            +
                      {children}
         | 
| 45 | 
            +
                    </QueryClientProvider>
         | 
| 46 | 
            +
                  </Provider>
         | 
| 47 | 
            +
                ),
         | 
| 48 | 
            +
              });
         | 
| 49 | 
            +
            };
         | 
| 50 | 
            +
             | 
| 51 | 
            +
            const MOCK_RESPOSITORIES: GitRepository[] = [
         | 
| 52 | 
            +
              {
         | 
| 53 | 
            +
                id: 1,
         | 
| 54 | 
            +
                full_name: "rbren/polaris",
         | 
| 55 | 
            +
                git_provider: "github",
         | 
| 56 | 
            +
                is_public: true,
         | 
| 57 | 
            +
              },
         | 
| 58 | 
            +
              {
         | 
| 59 | 
            +
                id: 2,
         | 
| 60 | 
            +
                full_name: "All-Hands-AI/OpenHands",
         | 
| 61 | 
            +
                git_provider: "github",
         | 
| 62 | 
            +
                is_public: true,
         | 
| 63 | 
            +
              },
         | 
| 64 | 
            +
            ];
         | 
| 65 | 
            +
             | 
| 66 | 
            +
            beforeEach(() => {
         | 
| 67 | 
            +
              const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
         | 
| 68 | 
            +
              getSettingsSpy.mockResolvedValue({
         | 
| 69 | 
            +
                ...MOCK_DEFAULT_USER_SETTINGS,
         | 
| 70 | 
            +
                provider_tokens_set: {
         | 
| 71 | 
            +
                  github: "some-token",
         | 
| 72 | 
            +
                  gitlab: null,
         | 
| 73 | 
            +
                },
         | 
| 74 | 
            +
              });
         | 
| 75 | 
            +
            });
         | 
| 76 | 
            +
             | 
| 77 | 
            +
            describe("RepoConnector", () => {
         | 
| 78 | 
            +
              it("should render the repository connector section", () => {
         | 
| 79 | 
            +
                renderRepoConnector();
         | 
| 80 | 
            +
                screen.getByTestId("repo-connector");
         | 
| 81 | 
            +
              });
         | 
| 82 | 
            +
             | 
| 83 | 
            +
              it("should render the available repositories in the dropdown", async () => {
         | 
| 84 | 
            +
                const retrieveUserGitRepositoriesSpy = vi.spyOn(
         | 
| 85 | 
            +
                  OpenHands,
         | 
| 86 | 
            +
                  "retrieveUserGitRepositories",
         | 
| 87 | 
            +
                );
         | 
| 88 | 
            +
                retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
         | 
| 89 | 
            +
             | 
| 90 | 
            +
                renderRepoConnector();
         | 
| 91 | 
            +
             | 
| 92 | 
            +
                // Wait for the loading state to be replaced with the dropdown
         | 
| 93 | 
            +
                const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
         | 
| 94 | 
            +
                await userEvent.click(dropdown);
         | 
| 95 | 
            +
             | 
| 96 | 
            +
                await waitFor(() => {
         | 
| 97 | 
            +
                  screen.getByText("rbren/polaris");
         | 
| 98 | 
            +
                  screen.getByText("All-Hands-AI/OpenHands");
         | 
| 99 | 
            +
                });
         | 
| 100 | 
            +
              });
         | 
| 101 | 
            +
             | 
| 102 | 
            +
              it("should only enable the launch button if a repo is selected", async () => {
         | 
| 103 | 
            +
                const retrieveUserGitRepositoriesSpy = vi.spyOn(
         | 
| 104 | 
            +
                  OpenHands,
         | 
| 105 | 
            +
                  "retrieveUserGitRepositories",
         | 
| 106 | 
            +
                );
         | 
| 107 | 
            +
                retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
         | 
| 108 | 
            +
             | 
| 109 | 
            +
                renderRepoConnector();
         | 
| 110 | 
            +
             | 
| 111 | 
            +
                const launchButton = await screen.findByTestId("repo-launch-button");
         | 
| 112 | 
            +
                expect(launchButton).toBeDisabled();
         | 
| 113 | 
            +
             | 
| 114 | 
            +
                // Wait for the loading state to be replaced with the dropdown
         | 
| 115 | 
            +
                const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
         | 
| 116 | 
            +
                await userEvent.click(dropdown);
         | 
| 117 | 
            +
                await userEvent.click(screen.getByText("rbren/polaris"));
         | 
| 118 | 
            +
             | 
| 119 | 
            +
                expect(launchButton).toBeEnabled();
         | 
| 120 | 
            +
              });
         | 
| 121 | 
            +
             | 
| 122 | 
            +
              it("should render the 'add git(hub|lab) repos' links if saas mode", async () => {
         | 
| 123 | 
            +
                const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
         | 
| 124 | 
            +
                // @ts-expect-error - only return the APP_MODE
         | 
| 125 | 
            +
                getConfiSpy.mockResolvedValue({
         | 
| 126 | 
            +
                  APP_MODE: "saas",
         | 
| 127 | 
            +
                });
         | 
| 128 | 
            +
             | 
| 129 | 
            +
                renderRepoConnector();
         | 
| 130 | 
            +
             | 
| 131 | 
            +
                await screen.findByText("Add GitHub repos");
         | 
| 132 | 
            +
              });
         | 
| 133 | 
            +
             | 
| 134 | 
            +
              it("should not render the 'add git(hub|lab) repos' links if oss mode", async () => {
         | 
| 135 | 
            +
                const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
         | 
| 136 | 
            +
                // @ts-expect-error - only return the APP_MODE
         | 
| 137 | 
            +
                getConfiSpy.mockResolvedValue({
         | 
| 138 | 
            +
                  APP_MODE: "oss",
         | 
| 139 | 
            +
                });
         | 
| 140 | 
            +
             | 
| 141 | 
            +
                renderRepoConnector();
         | 
| 142 | 
            +
             | 
| 143 | 
            +
                expect(screen.queryByText("Add GitHub repos")).not.toBeInTheDocument();
         | 
| 144 | 
            +
                expect(screen.queryByText("Add GitLab repos")).not.toBeInTheDocument();
         | 
| 145 | 
            +
              });
         | 
| 146 | 
            +
             | 
| 147 | 
            +
              it("should create a conversation and redirect with the selected repo when pressing the launch button", async () => {
         | 
| 148 | 
            +
                const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
         | 
| 149 | 
            +
                const retrieveUserGitRepositoriesSpy = vi.spyOn(
         | 
| 150 | 
            +
                  OpenHands,
         | 
| 151 | 
            +
                  "retrieveUserGitRepositories",
         | 
| 152 | 
            +
                );
         | 
| 153 | 
            +
                retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
         | 
| 154 | 
            +
             | 
| 155 | 
            +
                renderRepoConnector();
         | 
| 156 | 
            +
             | 
| 157 | 
            +
                const repoConnector = screen.getByTestId("repo-connector");
         | 
| 158 | 
            +
                const launchButton =
         | 
| 159 | 
            +
                  await within(repoConnector).findByTestId("repo-launch-button");
         | 
| 160 | 
            +
                await userEvent.click(launchButton);
         | 
| 161 | 
            +
             | 
| 162 | 
            +
                // repo not selected yet
         | 
| 163 | 
            +
                expect(createConversationSpy).not.toHaveBeenCalled();
         | 
| 164 | 
            +
             | 
| 165 | 
            +
                // select a repository from the dropdown
         | 
| 166 | 
            +
                const dropdown = await waitFor(() =>
         | 
| 167 | 
            +
                  within(repoConnector).getByTestId("repo-dropdown"),
         | 
| 168 | 
            +
                );
         | 
| 169 | 
            +
                await userEvent.click(dropdown);
         | 
| 170 | 
            +
             | 
| 171 | 
            +
                const repoOption = screen.getByText("rbren/polaris");
         | 
| 172 | 
            +
                await userEvent.click(repoOption);
         | 
| 173 | 
            +
                await userEvent.click(launchButton);
         | 
| 174 | 
            +
             | 
| 175 | 
            +
                expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
         | 
| 176 | 
            +
                  "rbren/polaris",
         | 
| 177 | 
            +
                  "github",
         | 
| 178 | 
            +
                  undefined,
         | 
| 179 | 
            +
                  [],
         | 
| 180 | 
            +
                  undefined,
         | 
| 181 | 
            +
                  undefined,
         | 
| 182 | 
            +
                  undefined,
         | 
| 183 | 
            +
                );
         | 
| 184 | 
            +
              });
         | 
| 185 | 
            +
             | 
| 186 | 
            +
              it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
         | 
| 187 | 
            +
                const retrieveUserGitRepositoriesSpy = vi.spyOn(
         | 
| 188 | 
            +
                  OpenHands,
         | 
| 189 | 
            +
                  "retrieveUserGitRepositories",
         | 
| 190 | 
            +
                );
         | 
| 191 | 
            +
                retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
         | 
| 192 | 
            +
             | 
| 193 | 
            +
                renderRepoConnector();
         | 
| 194 | 
            +
             | 
| 195 | 
            +
                const launchButton = await screen.findByTestId("repo-launch-button");
         | 
| 196 | 
            +
             | 
| 197 | 
            +
                // Wait for the loading state to be replaced with the dropdown
         | 
| 198 | 
            +
                const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
         | 
| 199 | 
            +
                await userEvent.click(dropdown);
         | 
| 200 | 
            +
                await userEvent.click(screen.getByText("rbren/polaris"));
         | 
| 201 | 
            +
             | 
| 202 | 
            +
                await userEvent.click(launchButton);
         | 
| 203 | 
            +
                expect(launchButton).toBeDisabled();
         | 
| 204 | 
            +
                expect(launchButton).toHaveTextContent(/Loading/i);
         | 
| 205 | 
            +
              });
         | 
| 206 | 
            +
             | 
| 207 | 
            +
              it("should not display a button to settings if the user is signed in with their git provider", async () => {
         | 
| 208 | 
            +
                renderRepoConnector();
         | 
| 209 | 
            +
             | 
| 210 | 
            +
                await waitFor(() => {
         | 
| 211 | 
            +
                  expect(
         | 
| 212 | 
            +
                    screen.queryByTestId("navigate-to-settings-button"),
         | 
| 213 | 
            +
                  ).not.toBeInTheDocument();
         | 
| 214 | 
            +
                });
         | 
| 215 | 
            +
              });
         | 
| 216 | 
            +
             | 
| 217 | 
            +
              it("should display a button to settings if the user needs to sign in with their git provider", async () => {
         | 
| 218 | 
            +
                const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
         | 
| 219 | 
            +
                getSettingsSpy.mockResolvedValue({
         | 
| 220 | 
            +
                  ...MOCK_DEFAULT_USER_SETTINGS,
         | 
| 221 | 
            +
                  provider_tokens_set: {},
         | 
| 222 | 
            +
                });
         | 
| 223 | 
            +
                renderRepoConnector();
         | 
| 224 | 
            +
             | 
| 225 | 
            +
                const goToSettingsButton = await screen.findByTestId(
         | 
| 226 | 
            +
                  "navigate-to-settings-button",
         | 
| 227 | 
            +
                );
         | 
| 228 | 
            +
                const dropdown = screen.queryByTestId("repo-dropdown");
         | 
| 229 | 
            +
                const launchButton = screen.queryByTestId("repo-launch-button");
         | 
| 230 | 
            +
                const providerLinks = screen.queryAllByText(/add git(hub|lab) repos/i);
         | 
| 231 | 
            +
             | 
| 232 | 
            +
                expect(dropdown).not.toBeInTheDocument();
         | 
| 233 | 
            +
                expect(launchButton).not.toBeInTheDocument();
         | 
| 234 | 
            +
                expect(providerLinks.length).toBe(0);
         | 
| 235 | 
            +
             | 
| 236 | 
            +
                expect(goToSettingsButton).toBeInTheDocument();
         | 
| 237 | 
            +
             | 
| 238 | 
            +
                await userEvent.click(goToSettingsButton);
         | 
| 239 | 
            +
                await screen.findByTestId("git-settings-screen");
         | 
| 240 | 
            +
              });
         | 
| 241 | 
            +
            });
         | 
    	
        frontend/__tests__/components/features/home/repo-selection-form.test.tsx
    ADDED
    
    | @@ -0,0 +1,259 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { render, screen } from "@testing-library/react";
         | 
| 2 | 
            +
            import { describe, expect, vi, beforeEach, it } from "vitest";
         | 
| 3 | 
            +
            import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
         | 
| 4 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 5 | 
            +
            import { RepositorySelectionForm } from "../../../../src/components/features/home/repo-selection-form";
         | 
| 6 | 
            +
            import OpenHands from "#/api/open-hands";
         | 
| 7 | 
            +
            import { GitRepository } from "#/types/git";
         | 
| 8 | 
            +
             | 
| 9 | 
            +
            // Create mock functions
         | 
| 10 | 
            +
            const mockUseUserRepositories = vi.fn();
         | 
| 11 | 
            +
            const mockUseCreateConversation = vi.fn();
         | 
| 12 | 
            +
            const mockUseIsCreatingConversation = vi.fn();
         | 
| 13 | 
            +
            const mockUseTranslation = vi.fn();
         | 
| 14 | 
            +
            const mockUseAuth = vi.fn();
         | 
| 15 | 
            +
             | 
| 16 | 
            +
            // Setup default mock returns
         | 
| 17 | 
            +
            mockUseUserRepositories.mockReturnValue({
         | 
| 18 | 
            +
              data: [],
         | 
| 19 | 
            +
              isLoading: false,
         | 
| 20 | 
            +
              isError: false,
         | 
| 21 | 
            +
            });
         | 
| 22 | 
            +
             | 
| 23 | 
            +
            mockUseCreateConversation.mockReturnValue({
         | 
| 24 | 
            +
              mutate: vi.fn(),
         | 
| 25 | 
            +
              isPending: false,
         | 
| 26 | 
            +
              isSuccess: false,
         | 
| 27 | 
            +
            });
         | 
| 28 | 
            +
             | 
| 29 | 
            +
            mockUseIsCreatingConversation.mockReturnValue(false);
         | 
| 30 | 
            +
             | 
| 31 | 
            +
            mockUseTranslation.mockReturnValue({ t: (key: string) => key });
         | 
| 32 | 
            +
             | 
| 33 | 
            +
            mockUseAuth.mockReturnValue({
         | 
| 34 | 
            +
              isAuthenticated: true,
         | 
| 35 | 
            +
              isLoading: false,
         | 
| 36 | 
            +
              providersAreSet: true,
         | 
| 37 | 
            +
              user: {
         | 
| 38 | 
            +
                id: 1,
         | 
| 39 | 
            +
                login: "testuser",
         | 
| 40 | 
            +
                avatar_url: "https://example.com/avatar.png",
         | 
| 41 | 
            +
                name: "Test User",
         | 
| 42 | 
            +
                email: "test@example.com",
         | 
| 43 | 
            +
                company: "Test Company",
         | 
| 44 | 
            +
              },
         | 
| 45 | 
            +
              login: vi.fn(),
         | 
| 46 | 
            +
              logout: vi.fn(),
         | 
| 47 | 
            +
            });
         | 
| 48 | 
            +
             | 
| 49 | 
            +
            vi.mock("#/hooks/mutation/use-create-conversation", () => ({
         | 
| 50 | 
            +
              useCreateConversation: () => mockUseCreateConversation(),
         | 
| 51 | 
            +
            }));
         | 
| 52 | 
            +
             | 
| 53 | 
            +
            vi.mock("#/hooks/use-is-creating-conversation", () => ({
         | 
| 54 | 
            +
              useIsCreatingConversation: () => mockUseIsCreatingConversation(),
         | 
| 55 | 
            +
            }));
         | 
| 56 | 
            +
             | 
| 57 | 
            +
            vi.mock("react-i18next", () => ({
         | 
| 58 | 
            +
              useTranslation: () => mockUseTranslation(),
         | 
| 59 | 
            +
            }));
         | 
| 60 | 
            +
             | 
| 61 | 
            +
            vi.mock("#/context/auth-context", () => ({
         | 
| 62 | 
            +
              useAuth: () => mockUseAuth(),
         | 
| 63 | 
            +
            }));
         | 
| 64 | 
            +
             | 
| 65 | 
            +
            vi.mock("#/hooks/use-debounce", () => ({
         | 
| 66 | 
            +
              useDebounce: (value: string) => value,
         | 
| 67 | 
            +
            }));
         | 
| 68 | 
            +
             | 
| 69 | 
            +
            const mockOnRepoSelection = vi.fn();
         | 
| 70 | 
            +
            const renderForm = () =>
         | 
| 71 | 
            +
              render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />, {
         | 
| 72 | 
            +
                wrapper: ({ children }) => (
         | 
| 73 | 
            +
                  <QueryClientProvider
         | 
| 74 | 
            +
                    client={
         | 
| 75 | 
            +
                      new QueryClient({
         | 
| 76 | 
            +
                        defaultOptions: {
         | 
| 77 | 
            +
                          queries: {
         | 
| 78 | 
            +
                            retry: false,
         | 
| 79 | 
            +
                          },
         | 
| 80 | 
            +
                        },
         | 
| 81 | 
            +
                      })
         | 
| 82 | 
            +
                    }
         | 
| 83 | 
            +
                  >
         | 
| 84 | 
            +
                    {children}
         | 
| 85 | 
            +
                  </QueryClientProvider>
         | 
| 86 | 
            +
                ),
         | 
| 87 | 
            +
              });
         | 
| 88 | 
            +
             | 
| 89 | 
            +
            describe("RepositorySelectionForm", () => {
         | 
| 90 | 
            +
              beforeEach(() => {
         | 
| 91 | 
            +
                vi.clearAllMocks();
         | 
| 92 | 
            +
              });
         | 
| 93 | 
            +
             | 
| 94 | 
            +
              it("shows loading indicator when repositories are being fetched", () => {
         | 
| 95 | 
            +
                const MOCK_REPOS: GitRepository[] = [
         | 
| 96 | 
            +
                  {
         | 
| 97 | 
            +
                    id: 1,
         | 
| 98 | 
            +
                    full_name: "user/repo1",
         | 
| 99 | 
            +
                    git_provider: "github",
         | 
| 100 | 
            +
                    is_public: true,
         | 
| 101 | 
            +
                  },
         | 
| 102 | 
            +
                  {
         | 
| 103 | 
            +
                    id: 2,
         | 
| 104 | 
            +
                    full_name: "user/repo2",
         | 
| 105 | 
            +
                    git_provider: "github",
         | 
| 106 | 
            +
                    is_public: true,
         | 
| 107 | 
            +
                  },
         | 
| 108 | 
            +
                ];
         | 
| 109 | 
            +
                const retrieveUserGitRepositoriesSpy = vi.spyOn(
         | 
| 110 | 
            +
                  OpenHands,
         | 
| 111 | 
            +
                  "retrieveUserGitRepositories",
         | 
| 112 | 
            +
                );
         | 
| 113 | 
            +
                retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
         | 
| 114 | 
            +
             | 
| 115 | 
            +
                renderForm();
         | 
| 116 | 
            +
             | 
| 117 | 
            +
                // Check if loading indicator is displayed
         | 
| 118 | 
            +
                expect(screen.getByTestId("repo-dropdown-loading")).toBeInTheDocument();
         | 
| 119 | 
            +
                expect(screen.getByText("HOME$LOADING_REPOSITORIES")).toBeInTheDocument();
         | 
| 120 | 
            +
              });
         | 
| 121 | 
            +
             | 
| 122 | 
            +
              it("shows dropdown when repositories are loaded", async () => {
         | 
| 123 | 
            +
                const MOCK_REPOS: GitRepository[] = [
         | 
| 124 | 
            +
                  {
         | 
| 125 | 
            +
                    id: 1,
         | 
| 126 | 
            +
                    full_name: "user/repo1",
         | 
| 127 | 
            +
                    git_provider: "github",
         | 
| 128 | 
            +
                    is_public: true,
         | 
| 129 | 
            +
                  },
         | 
| 130 | 
            +
                  {
         | 
| 131 | 
            +
                    id: 2,
         | 
| 132 | 
            +
                    full_name: "user/repo2",
         | 
| 133 | 
            +
                    git_provider: "github",
         | 
| 134 | 
            +
                    is_public: true,
         | 
| 135 | 
            +
                  },
         | 
| 136 | 
            +
                ];
         | 
| 137 | 
            +
                const retrieveUserGitRepositoriesSpy = vi.spyOn(
         | 
| 138 | 
            +
                  OpenHands,
         | 
| 139 | 
            +
                  "retrieveUserGitRepositories",
         | 
| 140 | 
            +
                );
         | 
| 141 | 
            +
                retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
         | 
| 142 | 
            +
             | 
| 143 | 
            +
                renderForm();
         | 
| 144 | 
            +
                expect(await screen.findByTestId("repo-dropdown")).toBeInTheDocument();
         | 
| 145 | 
            +
              });
         | 
| 146 | 
            +
             | 
| 147 | 
            +
              it("shows error message when repository fetch fails", async () => {
         | 
| 148 | 
            +
                const retrieveUserGitRepositoriesSpy = vi.spyOn(
         | 
| 149 | 
            +
                  OpenHands,
         | 
| 150 | 
            +
                  "retrieveUserGitRepositories",
         | 
| 151 | 
            +
                );
         | 
| 152 | 
            +
                retrieveUserGitRepositoriesSpy.mockRejectedValue(
         | 
| 153 | 
            +
                  new Error("Failed to load"),
         | 
| 154 | 
            +
                );
         | 
| 155 | 
            +
             | 
| 156 | 
            +
                renderForm();
         | 
| 157 | 
            +
             | 
| 158 | 
            +
                expect(
         | 
| 159 | 
            +
                  await screen.findByTestId("repo-dropdown-error"),
         | 
| 160 | 
            +
                ).toBeInTheDocument();
         | 
| 161 | 
            +
                expect(
         | 
| 162 | 
            +
                  screen.getByText("HOME$FAILED_TO_LOAD_REPOSITORIES"),
         | 
| 163 | 
            +
                ).toBeInTheDocument();
         | 
| 164 | 
            +
              });
         | 
| 165 | 
            +
             | 
| 166 | 
            +
              it("should call the search repos API when searching a URL", async () => {
         | 
| 167 | 
            +
                const MOCK_REPOS: GitRepository[] = [
         | 
| 168 | 
            +
                  {
         | 
| 169 | 
            +
                    id: 1,
         | 
| 170 | 
            +
                    full_name: "user/repo1",
         | 
| 171 | 
            +
                    git_provider: "github",
         | 
| 172 | 
            +
                    is_public: true,
         | 
| 173 | 
            +
                  },
         | 
| 174 | 
            +
                  {
         | 
| 175 | 
            +
                    id: 2,
         | 
| 176 | 
            +
                    full_name: "user/repo2",
         | 
| 177 | 
            +
                    git_provider: "github",
         | 
| 178 | 
            +
                    is_public: true,
         | 
| 179 | 
            +
                  },
         | 
| 180 | 
            +
                ];
         | 
| 181 | 
            +
             | 
| 182 | 
            +
                const MOCK_SEARCH_REPOS: GitRepository[] = [
         | 
| 183 | 
            +
                  {
         | 
| 184 | 
            +
                    id: 3,
         | 
| 185 | 
            +
                    full_name: "kubernetes/kubernetes",
         | 
| 186 | 
            +
                    git_provider: "github",
         | 
| 187 | 
            +
                    is_public: true,
         | 
| 188 | 
            +
                  },
         | 
| 189 | 
            +
                ];
         | 
| 190 | 
            +
             | 
| 191 | 
            +
                const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories");
         | 
| 192 | 
            +
                const retrieveUserGitRepositoriesSpy = vi.spyOn(
         | 
| 193 | 
            +
                  OpenHands,
         | 
| 194 | 
            +
                  "retrieveUserGitRepositories",
         | 
| 195 | 
            +
                );
         | 
| 196 | 
            +
             | 
| 197 | 
            +
                searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS);
         | 
| 198 | 
            +
                retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
         | 
| 199 | 
            +
             | 
| 200 | 
            +
                renderForm();
         | 
| 201 | 
            +
             | 
| 202 | 
            +
                const input = await screen.findByTestId("repo-dropdown");
         | 
| 203 | 
            +
                await userEvent.click(input);
         | 
| 204 | 
            +
             | 
| 205 | 
            +
                for (const repo of MOCK_REPOS) {
         | 
| 206 | 
            +
                  expect(screen.getByText(repo.full_name)).toBeInTheDocument();
         | 
| 207 | 
            +
                }
         | 
| 208 | 
            +
                expect(
         | 
| 209 | 
            +
                  screen.queryByText(MOCK_SEARCH_REPOS[0].full_name),
         | 
| 210 | 
            +
                ).not.toBeInTheDocument();
         | 
| 211 | 
            +
             | 
| 212 | 
            +
                expect(searchGitReposSpy).not.toHaveBeenCalled();
         | 
| 213 | 
            +
             | 
| 214 | 
            +
                await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
         | 
| 215 | 
            +
                expect(searchGitReposSpy).toHaveBeenLastCalledWith(
         | 
| 216 | 
            +
                  "kubernetes/kubernetes",
         | 
| 217 | 
            +
                  3,
         | 
| 218 | 
            +
                );
         | 
| 219 | 
            +
             | 
| 220 | 
            +
                expect(
         | 
| 221 | 
            +
                  screen.getByText(MOCK_SEARCH_REPOS[0].full_name),
         | 
| 222 | 
            +
                ).toBeInTheDocument();
         | 
| 223 | 
            +
                for (const repo of MOCK_REPOS) {
         | 
| 224 | 
            +
                  expect(screen.queryByText(repo.full_name)).not.toBeInTheDocument();
         | 
| 225 | 
            +
                }
         | 
| 226 | 
            +
              });
         | 
| 227 | 
            +
             | 
| 228 | 
            +
              it("should call onRepoSelection when a searched repository is selected", async () => {
         | 
| 229 | 
            +
                const MOCK_SEARCH_REPOS: GitRepository[] = [
         | 
| 230 | 
            +
                  {
         | 
| 231 | 
            +
                    id: 3,
         | 
| 232 | 
            +
                    full_name: "kubernetes/kubernetes",
         | 
| 233 | 
            +
                    git_provider: "github",
         | 
| 234 | 
            +
                    is_public: true,
         | 
| 235 | 
            +
                  },
         | 
| 236 | 
            +
                ];
         | 
| 237 | 
            +
             | 
| 238 | 
            +
                const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories");
         | 
| 239 | 
            +
                searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS);
         | 
| 240 | 
            +
             | 
| 241 | 
            +
                renderForm();
         | 
| 242 | 
            +
             | 
| 243 | 
            +
                const input = await screen.findByTestId("repo-dropdown");
         | 
| 244 | 
            +
             | 
| 245 | 
            +
                await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
         | 
| 246 | 
            +
                expect(searchGitReposSpy).toHaveBeenLastCalledWith(
         | 
| 247 | 
            +
                  "kubernetes/kubernetes",
         | 
| 248 | 
            +
                  3,
         | 
| 249 | 
            +
                );
         | 
| 250 | 
            +
             | 
| 251 | 
            +
                const searchedRepo = screen.getByText(MOCK_SEARCH_REPOS[0].full_name);
         | 
| 252 | 
            +
                expect(searchedRepo).toBeInTheDocument();
         | 
| 253 | 
            +
             | 
| 254 | 
            +
                await userEvent.click(searchedRepo);
         | 
| 255 | 
            +
                expect(mockOnRepoSelection).toHaveBeenCalledWith(
         | 
| 256 | 
            +
                  MOCK_SEARCH_REPOS[0].full_name,
         | 
| 257 | 
            +
                );
         | 
| 258 | 
            +
              });
         | 
| 259 | 
            +
            });
         | 
    	
        frontend/__tests__/components/features/home/task-card.test.tsx
    ADDED
    
    | @@ -0,0 +1,108 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { render, screen } from "@testing-library/react";
         | 
| 2 | 
            +
            import { beforeEach, describe, expect, it, vi } from "vitest";
         | 
| 3 | 
            +
            import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
         | 
| 4 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 5 | 
            +
            import { Provider } from "react-redux";
         | 
| 6 | 
            +
            import { createRoutesStub } from "react-router";
         | 
| 7 | 
            +
            import { setupStore } from "test-utils";
         | 
| 8 | 
            +
            import { SuggestedTask } from "#/components/features/home/tasks/task.types";
         | 
| 9 | 
            +
            import OpenHands from "#/api/open-hands";
         | 
| 10 | 
            +
            import { TaskCard } from "#/components/features/home/tasks/task-card";
         | 
| 11 | 
            +
            import { GitRepository } from "#/types/git";
         | 
| 12 | 
            +
             | 
| 13 | 
            +
            const MOCK_TASK_1: SuggestedTask = {
         | 
| 14 | 
            +
              issue_number: 123,
         | 
| 15 | 
            +
              repo: "repo1",
         | 
| 16 | 
            +
              title: "Task 1",
         | 
| 17 | 
            +
              task_type: "MERGE_CONFLICTS",
         | 
| 18 | 
            +
              git_provider: "github",
         | 
| 19 | 
            +
            };
         | 
| 20 | 
            +
             | 
| 21 | 
            +
            const MOCK_RESPOSITORIES: GitRepository[] = [
         | 
| 22 | 
            +
              { id: 1, full_name: "repo1", git_provider: "github", is_public: true },
         | 
| 23 | 
            +
              { id: 2, full_name: "repo2", git_provider: "github", is_public: true },
         | 
| 24 | 
            +
              { id: 3, full_name: "repo3", git_provider: "gitlab", is_public: true },
         | 
| 25 | 
            +
              { id: 4, full_name: "repo4", git_provider: "gitlab", is_public: true },
         | 
| 26 | 
            +
            ];
         | 
| 27 | 
            +
             | 
| 28 | 
            +
            const renderTaskCard = (task = MOCK_TASK_1) => {
         | 
| 29 | 
            +
              const RouterStub = createRoutesStub([
         | 
| 30 | 
            +
                {
         | 
| 31 | 
            +
                  Component: () => <TaskCard task={task} />,
         | 
| 32 | 
            +
                  path: "/",
         | 
| 33 | 
            +
                },
         | 
| 34 | 
            +
                {
         | 
| 35 | 
            +
                  Component: () => <div data-testid="conversation-screen" />,
         | 
| 36 | 
            +
                  path: "/conversations/:conversationId",
         | 
| 37 | 
            +
                },
         | 
| 38 | 
            +
              ]);
         | 
| 39 | 
            +
             | 
| 40 | 
            +
              return render(<RouterStub />, {
         | 
| 41 | 
            +
                wrapper: ({ children }) => (
         | 
| 42 | 
            +
                  <Provider store={setupStore()}>
         | 
| 43 | 
            +
                    <QueryClientProvider client={new QueryClient()}>
         | 
| 44 | 
            +
                      {children}
         | 
| 45 | 
            +
                    </QueryClientProvider>
         | 
| 46 | 
            +
                  </Provider>
         | 
| 47 | 
            +
                ),
         | 
| 48 | 
            +
              });
         | 
| 49 | 
            +
            };
         | 
| 50 | 
            +
             | 
| 51 | 
            +
            describe("TaskCard", () => {
         | 
| 52 | 
            +
              it("format the issue id", async () => {
         | 
| 53 | 
            +
                renderTaskCard();
         | 
| 54 | 
            +
             | 
| 55 | 
            +
                const taskId = screen.getByTestId("task-id");
         | 
| 56 | 
            +
                expect(taskId).toHaveTextContent(/#123/i);
         | 
| 57 | 
            +
              });
         | 
| 58 | 
            +
             | 
| 59 | 
            +
              it("should call createConversation when clicking the launch button", async () => {
         | 
| 60 | 
            +
                const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
         | 
| 61 | 
            +
             | 
| 62 | 
            +
                renderTaskCard();
         | 
| 63 | 
            +
             | 
| 64 | 
            +
                const launchButton = screen.getByTestId("task-launch-button");
         | 
| 65 | 
            +
                await userEvent.click(launchButton);
         | 
| 66 | 
            +
             | 
| 67 | 
            +
                expect(createConversationSpy).toHaveBeenCalled();
         | 
| 68 | 
            +
              });
         | 
| 69 | 
            +
             | 
| 70 | 
            +
              describe("creating suggested task conversation", () => {
         | 
| 71 | 
            +
                beforeEach(() => {
         | 
| 72 | 
            +
                  const retrieveUserGitRepositoriesSpy = vi.spyOn(
         | 
| 73 | 
            +
                    OpenHands,
         | 
| 74 | 
            +
                    "retrieveUserGitRepositories",
         | 
| 75 | 
            +
                  );
         | 
| 76 | 
            +
                  retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
         | 
| 77 | 
            +
                });
         | 
| 78 | 
            +
             | 
| 79 | 
            +
                it("should call create conversation with suggest task trigger and selected suggested task", async () => {
         | 
| 80 | 
            +
                  const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
         | 
| 81 | 
            +
             | 
| 82 | 
            +
                  renderTaskCard(MOCK_TASK_1);
         | 
| 83 | 
            +
             | 
| 84 | 
            +
                  const launchButton = screen.getByTestId("task-launch-button");
         | 
| 85 | 
            +
                  await userEvent.click(launchButton);
         | 
| 86 | 
            +
             | 
| 87 | 
            +
                  expect(createConversationSpy).toHaveBeenCalledWith(
         | 
| 88 | 
            +
                    MOCK_RESPOSITORIES[0].full_name,
         | 
| 89 | 
            +
                    MOCK_RESPOSITORIES[0].git_provider,
         | 
| 90 | 
            +
                    undefined,
         | 
| 91 | 
            +
                    [],
         | 
| 92 | 
            +
                    undefined,
         | 
| 93 | 
            +
                    MOCK_TASK_1,
         | 
| 94 | 
            +
                    undefined,
         | 
| 95 | 
            +
                  );
         | 
| 96 | 
            +
                });
         | 
| 97 | 
            +
              });
         | 
| 98 | 
            +
             | 
| 99 | 
            +
              it("should disable the launch button and update text content when creating a conversation", async () => {
         | 
| 100 | 
            +
                renderTaskCard();
         | 
| 101 | 
            +
             | 
| 102 | 
            +
                const launchButton = screen.getByTestId("task-launch-button");
         | 
| 103 | 
            +
                await userEvent.click(launchButton);
         | 
| 104 | 
            +
             | 
| 105 | 
            +
                expect(launchButton).toHaveTextContent(/Loading/i);
         | 
| 106 | 
            +
                expect(launchButton).toBeDisabled();
         | 
| 107 | 
            +
              });
         | 
| 108 | 
            +
            });
         | 
    	
        frontend/__tests__/components/features/home/task-suggestions.test.tsx
    ADDED
    
    | @@ -0,0 +1,96 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { render, screen, waitFor } from "@testing-library/react";
         | 
| 2 | 
            +
            import { afterEach, describe, expect, it, vi } from "vitest";
         | 
| 3 | 
            +
            import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
         | 
| 4 | 
            +
            import { Provider } from "react-redux";
         | 
| 5 | 
            +
            import { createRoutesStub } from "react-router";
         | 
| 6 | 
            +
            import { setupStore } from "test-utils";
         | 
| 7 | 
            +
            import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions";
         | 
| 8 | 
            +
            import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api";
         | 
| 9 | 
            +
            import { MOCK_TASKS } from "#/mocks/task-suggestions-handlers";
         | 
| 10 | 
            +
             | 
| 11 | 
            +
            const renderTaskSuggestions = () => {
         | 
| 12 | 
            +
              const RouterStub = createRoutesStub([
         | 
| 13 | 
            +
                {
         | 
| 14 | 
            +
                  Component: () => <TaskSuggestions />,
         | 
| 15 | 
            +
                  path: "/",
         | 
| 16 | 
            +
                },
         | 
| 17 | 
            +
                {
         | 
| 18 | 
            +
                  Component: () => <div data-testid="conversation-screen" />,
         | 
| 19 | 
            +
                  path: "/conversations/:conversationId",
         | 
| 20 | 
            +
                },
         | 
| 21 | 
            +
                {
         | 
| 22 | 
            +
                  Component: () => <div data-testid="settings-screen" />,
         | 
| 23 | 
            +
                  path: "/settings",
         | 
| 24 | 
            +
                },
         | 
| 25 | 
            +
              ]);
         | 
| 26 | 
            +
             | 
| 27 | 
            +
              return render(<RouterStub />, {
         | 
| 28 | 
            +
                wrapper: ({ children }) => (
         | 
| 29 | 
            +
                  <Provider store={setupStore()}>
         | 
| 30 | 
            +
                    <QueryClientProvider client={new QueryClient()}>
         | 
| 31 | 
            +
                      {children}
         | 
| 32 | 
            +
                    </QueryClientProvider>
         | 
| 33 | 
            +
                  </Provider>
         | 
| 34 | 
            +
                ),
         | 
| 35 | 
            +
              });
         | 
| 36 | 
            +
            };
         | 
| 37 | 
            +
             | 
| 38 | 
            +
            describe("TaskSuggestions", () => {
         | 
| 39 | 
            +
              const getSuggestedTasksSpy = vi.spyOn(
         | 
| 40 | 
            +
                SuggestionsService,
         | 
| 41 | 
            +
                "getSuggestedTasks",
         | 
| 42 | 
            +
              );
         | 
| 43 | 
            +
             | 
| 44 | 
            +
              afterEach(() => {
         | 
| 45 | 
            +
                vi.clearAllMocks();
         | 
| 46 | 
            +
              });
         | 
| 47 | 
            +
             | 
| 48 | 
            +
              it("should render the task suggestions section", () => {
         | 
| 49 | 
            +
                renderTaskSuggestions();
         | 
| 50 | 
            +
                screen.getByTestId("task-suggestions");
         | 
| 51 | 
            +
              });
         | 
| 52 | 
            +
             | 
| 53 | 
            +
              it("should render an empty message if there are no tasks", async () => {
         | 
| 54 | 
            +
                getSuggestedTasksSpy.mockResolvedValue([]);
         | 
| 55 | 
            +
                renderTaskSuggestions();
         | 
| 56 | 
            +
                await screen.findByText(/No tasks available/i);
         | 
| 57 | 
            +
              });
         | 
| 58 | 
            +
             | 
| 59 | 
            +
              it("should render the task groups with the correct titles", async () => {
         | 
| 60 | 
            +
                getSuggestedTasksSpy.mockResolvedValue(MOCK_TASKS);
         | 
| 61 | 
            +
                renderTaskSuggestions();
         | 
| 62 | 
            +
             | 
| 63 | 
            +
                await waitFor(() => {
         | 
| 64 | 
            +
                  MOCK_TASKS.forEach((taskGroup) => {
         | 
| 65 | 
            +
                    screen.getByText(taskGroup.title);
         | 
| 66 | 
            +
                  });
         | 
| 67 | 
            +
                });
         | 
| 68 | 
            +
              });
         | 
| 69 | 
            +
             | 
| 70 | 
            +
              it("should render the task cards with the correct task details", async () => {
         | 
| 71 | 
            +
                getSuggestedTasksSpy.mockResolvedValue(MOCK_TASKS);
         | 
| 72 | 
            +
                renderTaskSuggestions();
         | 
| 73 | 
            +
             | 
| 74 | 
            +
                await waitFor(() => {
         | 
| 75 | 
            +
                  MOCK_TASKS.forEach((task) => {
         | 
| 76 | 
            +
                    screen.getByText(task.title);
         | 
| 77 | 
            +
                  });
         | 
| 78 | 
            +
                });
         | 
| 79 | 
            +
              });
         | 
| 80 | 
            +
             | 
| 81 | 
            +
              it("should render skeletons when loading", async () => {
         | 
| 82 | 
            +
                getSuggestedTasksSpy.mockResolvedValue(MOCK_TASKS);
         | 
| 83 | 
            +
                renderTaskSuggestions();
         | 
| 84 | 
            +
             | 
| 85 | 
            +
                const skeletons = await screen.findAllByTestId("task-group-skeleton");
         | 
| 86 | 
            +
                expect(skeletons.length).toBeGreaterThan(0);
         | 
| 87 | 
            +
             | 
| 88 | 
            +
                await waitFor(() => {
         | 
| 89 | 
            +
                  MOCK_TASKS.forEach((taskGroup) => {
         | 
| 90 | 
            +
                    screen.getByText(taskGroup.title);
         | 
| 91 | 
            +
                  });
         | 
| 92 | 
            +
                });
         | 
| 93 | 
            +
             | 
| 94 | 
            +
                expect(screen.queryByTestId("task-group-skeleton")).not.toBeInTheDocument();
         | 
| 95 | 
            +
              });
         | 
| 96 | 
            +
            });
         | 
    	
        frontend/__tests__/components/features/payment/payment-form.test.tsx
    ADDED
    
    | @@ -0,0 +1,180 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
         | 
| 2 | 
            +
            import { render, screen, waitFor } from "@testing-library/react";
         | 
| 3 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 4 | 
            +
            import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
         | 
| 5 | 
            +
            import OpenHands from "#/api/open-hands";
         | 
| 6 | 
            +
            import { PaymentForm } from "#/components/features/payment/payment-form";
         | 
| 7 | 
            +
             | 
| 8 | 
            +
            describe("PaymentForm", () => {
         | 
| 9 | 
            +
              const getBalanceSpy = vi.spyOn(OpenHands, "getBalance");
         | 
| 10 | 
            +
              const createCheckoutSessionSpy = vi.spyOn(OpenHands, "createCheckoutSession");
         | 
| 11 | 
            +
              const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
         | 
| 12 | 
            +
             | 
| 13 | 
            +
              const renderPaymentForm = () =>
         | 
| 14 | 
            +
                render(<PaymentForm />, {
         | 
| 15 | 
            +
                  wrapper: ({ children }) => (
         | 
| 16 | 
            +
                    <QueryClientProvider client={new QueryClient()}>
         | 
| 17 | 
            +
                      {children}
         | 
| 18 | 
            +
                    </QueryClientProvider>
         | 
| 19 | 
            +
                  ),
         | 
| 20 | 
            +
                });
         | 
| 21 | 
            +
             | 
| 22 | 
            +
              beforeEach(() => {
         | 
| 23 | 
            +
                // useBalance hook will return the balance only if the APP_MODE is "saas" and the billing feature is enabled
         | 
| 24 | 
            +
                getConfigSpy.mockResolvedValue({
         | 
| 25 | 
            +
                  APP_MODE: "saas",
         | 
| 26 | 
            +
                  GITHUB_CLIENT_ID: "123",
         | 
| 27 | 
            +
                  POSTHOG_CLIENT_KEY: "456",
         | 
| 28 | 
            +
                  FEATURE_FLAGS: {
         | 
| 29 | 
            +
                    ENABLE_BILLING: true,
         | 
| 30 | 
            +
                    HIDE_LLM_SETTINGS: false,
         | 
| 31 | 
            +
                  },
         | 
| 32 | 
            +
                });
         | 
| 33 | 
            +
              });
         | 
| 34 | 
            +
             | 
| 35 | 
            +
              afterEach(() => {
         | 
| 36 | 
            +
                vi.clearAllMocks();
         | 
| 37 | 
            +
              });
         | 
| 38 | 
            +
             | 
| 39 | 
            +
              it("should render the users current balance", async () => {
         | 
| 40 | 
            +
                getBalanceSpy.mockResolvedValue("100.50");
         | 
| 41 | 
            +
                renderPaymentForm();
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                await waitFor(() => {
         | 
| 44 | 
            +
                  const balance = screen.getByTestId("user-balance");
         | 
| 45 | 
            +
                  expect(balance).toHaveTextContent("$100.50");
         | 
| 46 | 
            +
                });
         | 
| 47 | 
            +
              });
         | 
| 48 | 
            +
             | 
| 49 | 
            +
              it("should render the users current balance to two decimal places", async () => {
         | 
| 50 | 
            +
                getBalanceSpy.mockResolvedValue("100");
         | 
| 51 | 
            +
                renderPaymentForm();
         | 
| 52 | 
            +
             | 
| 53 | 
            +
                await waitFor(() => {
         | 
| 54 | 
            +
                  const balance = screen.getByTestId("user-balance");
         | 
| 55 | 
            +
                  expect(balance).toHaveTextContent("$100.00");
         | 
| 56 | 
            +
                });
         | 
| 57 | 
            +
              });
         | 
| 58 | 
            +
             | 
| 59 | 
            +
              test("the user can top-up a specific amount", async () => {
         | 
| 60 | 
            +
                const user = userEvent.setup();
         | 
| 61 | 
            +
                renderPaymentForm();
         | 
| 62 | 
            +
             | 
| 63 | 
            +
                const topUpInput = await screen.findByTestId("top-up-input");
         | 
| 64 | 
            +
                await user.type(topUpInput, "50");
         | 
| 65 | 
            +
             | 
| 66 | 
            +
                const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
         | 
| 67 | 
            +
                await user.click(topUpButton);
         | 
| 68 | 
            +
             | 
| 69 | 
            +
                expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50);
         | 
| 70 | 
            +
              });
         | 
| 71 | 
            +
             | 
| 72 | 
            +
              it("should only accept integer values", async () => {
         | 
| 73 | 
            +
                const user = userEvent.setup();
         | 
| 74 | 
            +
                renderPaymentForm();
         | 
| 75 | 
            +
             | 
| 76 | 
            +
                const topUpInput = await screen.findByTestId("top-up-input");
         | 
| 77 | 
            +
                await user.type(topUpInput, "50");
         | 
| 78 | 
            +
             | 
| 79 | 
            +
                const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
         | 
| 80 | 
            +
                await user.click(topUpButton);
         | 
| 81 | 
            +
             | 
| 82 | 
            +
                expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50);
         | 
| 83 | 
            +
              });
         | 
| 84 | 
            +
             | 
| 85 | 
            +
              it("should disable the top-up button if the user enters an invalid amount", async () => {
         | 
| 86 | 
            +
                const user = userEvent.setup();
         | 
| 87 | 
            +
                renderPaymentForm();
         | 
| 88 | 
            +
             | 
| 89 | 
            +
                const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
         | 
| 90 | 
            +
                expect(topUpButton).toBeDisabled();
         | 
| 91 | 
            +
             | 
| 92 | 
            +
                const topUpInput = await screen.findByTestId("top-up-input");
         | 
| 93 | 
            +
                await user.type(topUpInput, "  ");
         | 
| 94 | 
            +
             | 
| 95 | 
            +
                expect(topUpButton).toBeDisabled();
         | 
| 96 | 
            +
              });
         | 
| 97 | 
            +
             | 
| 98 | 
            +
              it("should disable the top-up button after submission", async () => {
         | 
| 99 | 
            +
                const user = userEvent.setup();
         | 
| 100 | 
            +
                renderPaymentForm();
         | 
| 101 | 
            +
             | 
| 102 | 
            +
                const topUpInput = await screen.findByTestId("top-up-input");
         | 
| 103 | 
            +
                await user.type(topUpInput, "50");
         | 
| 104 | 
            +
             | 
| 105 | 
            +
                const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
         | 
| 106 | 
            +
                await user.click(topUpButton);
         | 
| 107 | 
            +
             | 
| 108 | 
            +
                expect(topUpButton).toBeDisabled();
         | 
| 109 | 
            +
              });
         | 
| 110 | 
            +
             | 
| 111 | 
            +
              describe("prevent submission if", () => {
         | 
| 112 | 
            +
                test("user enters a negative amount", async () => {
         | 
| 113 | 
            +
                  const user = userEvent.setup();
         | 
| 114 | 
            +
                  renderPaymentForm();
         | 
| 115 | 
            +
             | 
| 116 | 
            +
                  const topUpInput = await screen.findByTestId("top-up-input");
         | 
| 117 | 
            +
                  await user.type(topUpInput, "-50");
         | 
| 118 | 
            +
             | 
| 119 | 
            +
                  const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
         | 
| 120 | 
            +
                  await user.click(topUpButton);
         | 
| 121 | 
            +
             | 
| 122 | 
            +
                  expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
         | 
| 123 | 
            +
                });
         | 
| 124 | 
            +
             | 
| 125 | 
            +
                test("user enters an empty string", async () => {
         | 
| 126 | 
            +
                  const user = userEvent.setup();
         | 
| 127 | 
            +
                  renderPaymentForm();
         | 
| 128 | 
            +
             | 
| 129 | 
            +
                  const topUpInput = await screen.findByTestId("top-up-input");
         | 
| 130 | 
            +
                  await user.type(topUpInput, "     ");
         | 
| 131 | 
            +
             | 
| 132 | 
            +
                  const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
         | 
| 133 | 
            +
                  await user.click(topUpButton);
         | 
| 134 | 
            +
             | 
| 135 | 
            +
                  expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
         | 
| 136 | 
            +
                });
         | 
| 137 | 
            +
             | 
| 138 | 
            +
                test("user enters a non-numeric value", async () => {
         | 
| 139 | 
            +
                  const user = userEvent.setup();
         | 
| 140 | 
            +
                  renderPaymentForm();
         | 
| 141 | 
            +
             | 
| 142 | 
            +
                  // With type="number", the browser would prevent non-numeric input,
         | 
| 143 | 
            +
                  // but we'll test the validation logic anyway
         | 
| 144 | 
            +
                  const topUpInput = await screen.findByTestId("top-up-input");
         | 
| 145 | 
            +
                  await user.type(topUpInput, "abc");
         | 
| 146 | 
            +
             | 
| 147 | 
            +
                  const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
         | 
| 148 | 
            +
                  await user.click(topUpButton);
         | 
| 149 | 
            +
             | 
| 150 | 
            +
                  expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
         | 
| 151 | 
            +
                });
         | 
| 152 | 
            +
             | 
| 153 | 
            +
                test("user enters less than the minimum amount", async () => {
         | 
| 154 | 
            +
                  const user = userEvent.setup();
         | 
| 155 | 
            +
                  renderPaymentForm();
         | 
| 156 | 
            +
             | 
| 157 | 
            +
                  const topUpInput = await screen.findByTestId("top-up-input");
         | 
| 158 | 
            +
                  await user.type(topUpInput, "9"); // test assumes the minimum is 10
         | 
| 159 | 
            +
             | 
| 160 | 
            +
                  const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
         | 
| 161 | 
            +
                  await user.click(topUpButton);
         | 
| 162 | 
            +
             | 
| 163 | 
            +
                  expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
         | 
| 164 | 
            +
                });
         | 
| 165 | 
            +
             | 
| 166 | 
            +
                test("user enters a decimal value", async () => {
         | 
| 167 | 
            +
                  const user = userEvent.setup();
         | 
| 168 | 
            +
                  renderPaymentForm();
         | 
| 169 | 
            +
             | 
| 170 | 
            +
                  // With step="1", the browser would validate this, but we'll test our validation logic
         | 
| 171 | 
            +
                  const topUpInput = await screen.findByTestId("top-up-input");
         | 
| 172 | 
            +
                  await user.type(topUpInput, "50.5");
         | 
| 173 | 
            +
             | 
| 174 | 
            +
                  const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
         | 
| 175 | 
            +
                  await user.click(topUpButton);
         | 
| 176 | 
            +
             | 
| 177 | 
            +
                  expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
         | 
| 178 | 
            +
                });
         | 
| 179 | 
            +
              });
         | 
| 180 | 
            +
            });
         | 
    	
        frontend/__tests__/components/features/settings/api-keys-manager.test.tsx
    ADDED
    
    | @@ -0,0 +1,59 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { render, screen } from "@testing-library/react";
         | 
| 2 | 
            +
            import { describe, expect, it, vi } from "vitest";
         | 
| 3 | 
            +
            import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
         | 
| 4 | 
            +
            import { ApiKeysManager } from "#/components/features/settings/api-keys-manager";
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            // Mock the react-i18next
         | 
| 7 | 
            +
            vi.mock("react-i18next", async () => {
         | 
| 8 | 
            +
              const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
         | 
| 9 | 
            +
              return {
         | 
| 10 | 
            +
                ...actual,
         | 
| 11 | 
            +
                useTranslation: () => ({
         | 
| 12 | 
            +
                  t: (key: string) => key,
         | 
| 13 | 
            +
                }),
         | 
| 14 | 
            +
                Trans: ({ i18nKey, components }: { i18nKey: string; components: Record<string, React.ReactNode> }) => {
         | 
| 15 | 
            +
                  // Simplified Trans component that renders the link
         | 
| 16 | 
            +
                  if (i18nKey === "SETTINGS$API_KEYS_DESCRIPTION") {
         | 
| 17 | 
            +
                    return (
         | 
| 18 | 
            +
                      <span>
         | 
| 19 | 
            +
                        API keys allow you to authenticate with the OpenHands API programmatically. 
         | 
| 20 | 
            +
                        Keep your API keys secure; anyone with your API key can access your account. 
         | 
| 21 | 
            +
                        For more information on how to use the API, see our {components.a}
         | 
| 22 | 
            +
                      </span>
         | 
| 23 | 
            +
                    );
         | 
| 24 | 
            +
                  }
         | 
| 25 | 
            +
                  return <span>{i18nKey}</span>;
         | 
| 26 | 
            +
                },
         | 
| 27 | 
            +
              };
         | 
| 28 | 
            +
            });
         | 
| 29 | 
            +
             | 
| 30 | 
            +
            // Mock the API keys hook
         | 
| 31 | 
            +
            vi.mock("#/hooks/query/use-api-keys", () => ({
         | 
| 32 | 
            +
              useApiKeys: () => ({
         | 
| 33 | 
            +
                data: [],
         | 
| 34 | 
            +
                isLoading: false,
         | 
| 35 | 
            +
                error: null,
         | 
| 36 | 
            +
              }),
         | 
| 37 | 
            +
            }));
         | 
| 38 | 
            +
             | 
| 39 | 
            +
            describe("ApiKeysManager", () => {
         | 
| 40 | 
            +
              const renderComponent = () => {
         | 
| 41 | 
            +
                const queryClient = new QueryClient();
         | 
| 42 | 
            +
                return render(
         | 
| 43 | 
            +
                  <QueryClientProvider client={queryClient}>
         | 
| 44 | 
            +
                    <ApiKeysManager />
         | 
| 45 | 
            +
                  </QueryClientProvider>
         | 
| 46 | 
            +
                );
         | 
| 47 | 
            +
              };
         | 
| 48 | 
            +
             | 
| 49 | 
            +
              it("should render the API documentation link", () => {
         | 
| 50 | 
            +
                renderComponent();
         | 
| 51 | 
            +
                
         | 
| 52 | 
            +
                // Find the link to the API documentation
         | 
| 53 | 
            +
                const link = screen.getByRole("link");
         | 
| 54 | 
            +
                expect(link).toBeInTheDocument();
         | 
| 55 | 
            +
                expect(link).toHaveAttribute("href", "https://docs.all-hands.dev/usage/cloud/cloud-api");
         | 
| 56 | 
            +
                expect(link).toHaveAttribute("target", "_blank");
         | 
| 57 | 
            +
                expect(link).toHaveAttribute("rel", "noopener noreferrer");
         | 
| 58 | 
            +
              });
         | 
| 59 | 
            +
            });
         | 
    	
        frontend/__tests__/components/features/sidebar/sidebar.test.tsx
    ADDED
    
    | @@ -0,0 +1,32 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { afterEach, describe, expect, it, vi } from "vitest";
         | 
| 2 | 
            +
            import { renderWithProviders } from "test-utils";
         | 
| 3 | 
            +
            import { createRoutesStub } from "react-router";
         | 
| 4 | 
            +
            import { waitFor } from "@testing-library/react";
         | 
| 5 | 
            +
            import { Sidebar } from "#/components/features/sidebar/sidebar";
         | 
| 6 | 
            +
            import OpenHands from "#/api/open-hands";
         | 
| 7 | 
            +
             | 
| 8 | 
            +
            // These tests will now fail because the conversation panel is rendered through a portal
         | 
| 9 | 
            +
            // and technically not a child of the Sidebar component.
         | 
| 10 | 
            +
             | 
| 11 | 
            +
            const RouterStub = createRoutesStub([
         | 
| 12 | 
            +
              {
         | 
| 13 | 
            +
                path: "/conversation/:conversationId",
         | 
| 14 | 
            +
                Component: () => <Sidebar />,
         | 
| 15 | 
            +
              },
         | 
| 16 | 
            +
            ]);
         | 
| 17 | 
            +
             | 
| 18 | 
            +
            const renderSidebar = () =>
         | 
| 19 | 
            +
              renderWithProviders(<RouterStub initialEntries={["/conversation/123"]} />);
         | 
| 20 | 
            +
             | 
| 21 | 
            +
            describe("Sidebar", () => {
         | 
| 22 | 
            +
              const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
         | 
| 23 | 
            +
             | 
| 24 | 
            +
              afterEach(() => {
         | 
| 25 | 
            +
                vi.clearAllMocks();
         | 
| 26 | 
            +
              });
         | 
| 27 | 
            +
             | 
| 28 | 
            +
              it("should fetch settings data on mount", async () => {
         | 
| 29 | 
            +
                renderSidebar();
         | 
| 30 | 
            +
                await waitFor(() => expect(getSettingsSpy).toHaveBeenCalled());
         | 
| 31 | 
            +
              });
         | 
| 32 | 
            +
            });
         | 
    	
        frontend/__tests__/components/feedback-actions.test.tsx
    ADDED
    
    | @@ -0,0 +1,76 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { screen, within } from "@testing-library/react";
         | 
| 2 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 3 | 
            +
            import { afterEach, describe, expect, it, vi } from "vitest";
         | 
| 4 | 
            +
            import { renderWithProviders } from "test-utils";
         | 
| 5 | 
            +
            import { TrajectoryActions } from "#/components/features/trajectory/trajectory-actions";
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            describe("TrajectoryActions", () => {
         | 
| 8 | 
            +
              const user = userEvent.setup();
         | 
| 9 | 
            +
              const onPositiveFeedback = vi.fn();
         | 
| 10 | 
            +
              const onNegativeFeedback = vi.fn();
         | 
| 11 | 
            +
              const onExportTrajectory = vi.fn();
         | 
| 12 | 
            +
             | 
| 13 | 
            +
              afterEach(() => {
         | 
| 14 | 
            +
                vi.clearAllMocks();
         | 
| 15 | 
            +
              });
         | 
| 16 | 
            +
             | 
| 17 | 
            +
              it("should render correctly", () => {
         | 
| 18 | 
            +
                renderWithProviders(
         | 
| 19 | 
            +
                  <TrajectoryActions
         | 
| 20 | 
            +
                    onPositiveFeedback={onPositiveFeedback}
         | 
| 21 | 
            +
                    onNegativeFeedback={onNegativeFeedback}
         | 
| 22 | 
            +
                    onExportTrajectory={onExportTrajectory}
         | 
| 23 | 
            +
                  />,
         | 
| 24 | 
            +
                );
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                const actions = screen.getByTestId("feedback-actions");
         | 
| 27 | 
            +
                within(actions).getByTestId("positive-feedback");
         | 
| 28 | 
            +
                within(actions).getByTestId("negative-feedback");
         | 
| 29 | 
            +
                within(actions).getByTestId("export-trajectory");
         | 
| 30 | 
            +
              });
         | 
| 31 | 
            +
             | 
| 32 | 
            +
              it("should call onPositiveFeedback when positive feedback is clicked", async () => {
         | 
| 33 | 
            +
                renderWithProviders(
         | 
| 34 | 
            +
                  <TrajectoryActions
         | 
| 35 | 
            +
                    onPositiveFeedback={onPositiveFeedback}
         | 
| 36 | 
            +
                    onNegativeFeedback={onNegativeFeedback}
         | 
| 37 | 
            +
                    onExportTrajectory={onExportTrajectory}
         | 
| 38 | 
            +
                  />,
         | 
| 39 | 
            +
                );
         | 
| 40 | 
            +
             | 
| 41 | 
            +
                const positiveFeedback = screen.getByTestId("positive-feedback");
         | 
| 42 | 
            +
                await user.click(positiveFeedback);
         | 
| 43 | 
            +
             | 
| 44 | 
            +
                expect(onPositiveFeedback).toHaveBeenCalled();
         | 
| 45 | 
            +
              });
         | 
| 46 | 
            +
             | 
| 47 | 
            +
              it("should call onNegativeFeedback when negative feedback is clicked", async () => {
         | 
| 48 | 
            +
                renderWithProviders(
         | 
| 49 | 
            +
                  <TrajectoryActions
         | 
| 50 | 
            +
                    onPositiveFeedback={onPositiveFeedback}
         | 
| 51 | 
            +
                    onNegativeFeedback={onNegativeFeedback}
         | 
| 52 | 
            +
                    onExportTrajectory={onExportTrajectory}
         | 
| 53 | 
            +
                  />,
         | 
| 54 | 
            +
                );
         | 
| 55 | 
            +
             | 
| 56 | 
            +
                const negativeFeedback = screen.getByTestId("negative-feedback");
         | 
| 57 | 
            +
                await user.click(negativeFeedback);
         | 
| 58 | 
            +
             | 
| 59 | 
            +
                expect(onNegativeFeedback).toHaveBeenCalled();
         | 
| 60 | 
            +
              });
         | 
| 61 | 
            +
             | 
| 62 | 
            +
              it("should call onExportTrajectory when export button is clicked", async () => {
         | 
| 63 | 
            +
                renderWithProviders(
         | 
| 64 | 
            +
                  <TrajectoryActions
         | 
| 65 | 
            +
                    onPositiveFeedback={onPositiveFeedback}
         | 
| 66 | 
            +
                    onNegativeFeedback={onNegativeFeedback}
         | 
| 67 | 
            +
                    onExportTrajectory={onExportTrajectory}
         | 
| 68 | 
            +
                  />,
         | 
| 69 | 
            +
                );
         | 
| 70 | 
            +
             | 
| 71 | 
            +
                const exportButton = screen.getByTestId("export-trajectory");
         | 
| 72 | 
            +
                await user.click(exportButton);
         | 
| 73 | 
            +
             | 
| 74 | 
            +
                expect(onExportTrajectory).toHaveBeenCalled();
         | 
| 75 | 
            +
              });
         | 
| 76 | 
            +
            });
         | 
    	
        frontend/__tests__/components/feedback-form.test.tsx
    ADDED
    
    | @@ -0,0 +1,68 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { afterEach, describe, expect, it, vi } from "vitest";
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            // Mock useParams before importing components
         | 
| 4 | 
            +
            vi.mock("react-router", async () => {
         | 
| 5 | 
            +
              const actual = await vi.importActual("react-router");
         | 
| 6 | 
            +
              return {
         | 
| 7 | 
            +
                ...(actual as object),
         | 
| 8 | 
            +
                useParams: () => ({ conversationId: "test-conversation-id" }),
         | 
| 9 | 
            +
              };
         | 
| 10 | 
            +
            });
         | 
| 11 | 
            +
             | 
| 12 | 
            +
            import { screen } from "@testing-library/react";
         | 
| 13 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 14 | 
            +
            import { renderWithProviders } from "test-utils";
         | 
| 15 | 
            +
            import { FeedbackForm } from "#/components/features/feedback/feedback-form";
         | 
| 16 | 
            +
            import { I18nKey } from "#/i18n/declaration";
         | 
| 17 | 
            +
             | 
| 18 | 
            +
            describe("FeedbackForm", () => {
         | 
| 19 | 
            +
              const user = userEvent.setup();
         | 
| 20 | 
            +
              const onCloseMock = vi.fn();
         | 
| 21 | 
            +
             | 
| 22 | 
            +
              afterEach(() => {
         | 
| 23 | 
            +
                vi.clearAllMocks();
         | 
| 24 | 
            +
              });
         | 
| 25 | 
            +
             | 
| 26 | 
            +
              it("should render correctly", () => {
         | 
| 27 | 
            +
                renderWithProviders(
         | 
| 28 | 
            +
                  <FeedbackForm polarity="positive" onClose={onCloseMock} />,
         | 
| 29 | 
            +
                );
         | 
| 30 | 
            +
             | 
| 31 | 
            +
                screen.getByLabelText(I18nKey.FEEDBACK$EMAIL_LABEL);
         | 
| 32 | 
            +
                screen.getByLabelText(I18nKey.FEEDBACK$PRIVATE_LABEL);
         | 
| 33 | 
            +
                screen.getByLabelText(I18nKey.FEEDBACK$PUBLIC_LABEL);
         | 
| 34 | 
            +
             | 
| 35 | 
            +
                screen.getByRole("button", { name: I18nKey.FEEDBACK$SHARE_LABEL });
         | 
| 36 | 
            +
                screen.getByRole("button", { name: I18nKey.FEEDBACK$CANCEL_LABEL });
         | 
| 37 | 
            +
              });
         | 
| 38 | 
            +
             | 
| 39 | 
            +
              it("should switch between private and public permissions", async () => {
         | 
| 40 | 
            +
                renderWithProviders(
         | 
| 41 | 
            +
                  <FeedbackForm polarity="positive" onClose={onCloseMock} />,
         | 
| 42 | 
            +
                );
         | 
| 43 | 
            +
                const privateRadio = screen.getByLabelText(I18nKey.FEEDBACK$PRIVATE_LABEL);
         | 
| 44 | 
            +
                const publicRadio = screen.getByLabelText(I18nKey.FEEDBACK$PUBLIC_LABEL);
         | 
| 45 | 
            +
             | 
| 46 | 
            +
                expect(privateRadio).toBeChecked(); // private is the default value
         | 
| 47 | 
            +
                expect(publicRadio).not.toBeChecked();
         | 
| 48 | 
            +
             | 
| 49 | 
            +
                await user.click(publicRadio);
         | 
| 50 | 
            +
                expect(publicRadio).toBeChecked();
         | 
| 51 | 
            +
                expect(privateRadio).not.toBeChecked();
         | 
| 52 | 
            +
             | 
| 53 | 
            +
                await user.click(privateRadio);
         | 
| 54 | 
            +
                expect(privateRadio).toBeChecked();
         | 
| 55 | 
            +
                expect(publicRadio).not.toBeChecked();
         | 
| 56 | 
            +
              });
         | 
| 57 | 
            +
             | 
| 58 | 
            +
              it("should call onClose when the close button is clicked", async () => {
         | 
| 59 | 
            +
                renderWithProviders(
         | 
| 60 | 
            +
                  <FeedbackForm polarity="positive" onClose={onCloseMock} />,
         | 
| 61 | 
            +
                );
         | 
| 62 | 
            +
                await user.click(
         | 
| 63 | 
            +
                  screen.getByRole("button", { name: I18nKey.FEEDBACK$CANCEL_LABEL }),
         | 
| 64 | 
            +
                );
         | 
| 65 | 
            +
             | 
| 66 | 
            +
                expect(onCloseMock).toHaveBeenCalled();
         | 
| 67 | 
            +
              });
         | 
| 68 | 
            +
            });
         | 
    	
        frontend/__tests__/components/file-operations.test.tsx
    ADDED
    
    | @@ -0,0 +1,11 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { describe, it } from "vitest";
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            describe("File Operations Messages", () => {
         | 
| 4 | 
            +
              it.todo("should show success indicator for successful file read operation");
         | 
| 5 | 
            +
             | 
| 6 | 
            +
              it.todo("should show failure indicator for failed file read operation");
         | 
| 7 | 
            +
             | 
| 8 | 
            +
              it.todo("should show success indicator for successful file edit operation");
         | 
| 9 | 
            +
             | 
| 10 | 
            +
              it.todo("should show failure indicator for failed file edit operation");
         | 
| 11 | 
            +
            });
         | 
    	
        frontend/__tests__/components/image-preview.test.tsx
    ADDED
    
    | @@ -0,0 +1,37 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { ImagePreview } from "#/components/features/images/image-preview";
         | 
| 2 | 
            +
            import { render, screen } from "@testing-library/react";
         | 
| 3 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 4 | 
            +
            import { describe, expect, it, vi } from "vitest";
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            describe("ImagePreview", () => {
         | 
| 7 | 
            +
              it("should render an image", () => {
         | 
| 8 | 
            +
                render(
         | 
| 9 | 
            +
                  <ImagePreview src="https://example.com/image.jpg" onRemove={vi.fn} />,
         | 
| 10 | 
            +
                );
         | 
| 11 | 
            +
                const img = screen.getByRole("img");
         | 
| 12 | 
            +
             | 
| 13 | 
            +
                expect(screen.getByTestId("image-preview")).toBeInTheDocument();
         | 
| 14 | 
            +
                expect(img).toHaveAttribute("src", "https://example.com/image.jpg");
         | 
| 15 | 
            +
              });
         | 
| 16 | 
            +
             | 
| 17 | 
            +
              it("should call onRemove when the close button is clicked", async () => {
         | 
| 18 | 
            +
                const user = userEvent.setup();
         | 
| 19 | 
            +
                const onRemoveMock = vi.fn();
         | 
| 20 | 
            +
                render(
         | 
| 21 | 
            +
                  <ImagePreview
         | 
| 22 | 
            +
                    src="https://example.com/image.jpg"
         | 
| 23 | 
            +
                    onRemove={onRemoveMock}
         | 
| 24 | 
            +
                  />,
         | 
| 25 | 
            +
                );
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                const closeButton = screen.getByRole("button");
         | 
| 28 | 
            +
                await user.click(closeButton);
         | 
| 29 | 
            +
             | 
| 30 | 
            +
                expect(onRemoveMock).toHaveBeenCalledOnce();
         | 
| 31 | 
            +
              });
         | 
| 32 | 
            +
             | 
| 33 | 
            +
              it("shoud not display the close button when onRemove is not provided", () => {
         | 
| 34 | 
            +
                render(<ImagePreview src="https://example.com/image.jpg" />);
         | 
| 35 | 
            +
                expect(screen.queryByRole("button")).not.toBeInTheDocument();
         | 
| 36 | 
            +
              });
         | 
| 37 | 
            +
            });
         | 
    	
        frontend/__tests__/components/interactive-chat-box.test.tsx
    ADDED
    
    | @@ -0,0 +1,190 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { render, screen, within, fireEvent } from "@testing-library/react";
         | 
| 2 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 3 | 
            +
            import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
         | 
| 4 | 
            +
            import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            describe("InteractiveChatBox", () => {
         | 
| 7 | 
            +
              const onSubmitMock = vi.fn();
         | 
| 8 | 
            +
              const onStopMock = vi.fn();
         | 
| 9 | 
            +
             | 
| 10 | 
            +
              beforeAll(() => {
         | 
| 11 | 
            +
                global.URL.createObjectURL = vi
         | 
| 12 | 
            +
                  .fn()
         | 
| 13 | 
            +
                  .mockReturnValue("blob:http://example.com");
         | 
| 14 | 
            +
              });
         | 
| 15 | 
            +
             | 
| 16 | 
            +
              afterEach(() => {
         | 
| 17 | 
            +
                vi.clearAllMocks();
         | 
| 18 | 
            +
              });
         | 
| 19 | 
            +
             | 
| 20 | 
            +
              it("should render", () => {
         | 
| 21 | 
            +
                render(<InteractiveChatBox onSubmit={onSubmitMock} onStop={onStopMock} />);
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                const chatBox = screen.getByTestId("interactive-chat-box");
         | 
| 24 | 
            +
                within(chatBox).getByTestId("chat-input");
         | 
| 25 | 
            +
                within(chatBox).getByTestId("upload-image-input");
         | 
| 26 | 
            +
              });
         | 
| 27 | 
            +
             | 
| 28 | 
            +
              it.fails("should set custom values", () => {
         | 
| 29 | 
            +
                render(
         | 
| 30 | 
            +
                  <InteractiveChatBox
         | 
| 31 | 
            +
                    onSubmit={onSubmitMock}
         | 
| 32 | 
            +
                    onStop={onStopMock}
         | 
| 33 | 
            +
                    value="Hello, world!"
         | 
| 34 | 
            +
                  />,
         | 
| 35 | 
            +
                );
         | 
| 36 | 
            +
             | 
| 37 | 
            +
                const chatBox = screen.getByTestId("interactive-chat-box");
         | 
| 38 | 
            +
                const chatInput = within(chatBox).getByTestId("chat-input");
         | 
| 39 | 
            +
             | 
| 40 | 
            +
                expect(chatInput).toHaveValue("Hello, world!");
         | 
| 41 | 
            +
              });
         | 
| 42 | 
            +
             | 
| 43 | 
            +
              it("should display the image previews when images are uploaded", async () => {
         | 
| 44 | 
            +
                const user = userEvent.setup();
         | 
| 45 | 
            +
                render(<InteractiveChatBox onSubmit={onSubmitMock} onStop={onStopMock} />);
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                const file = new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" });
         | 
| 48 | 
            +
                const input = screen.getByTestId("upload-image-input");
         | 
| 49 | 
            +
             | 
| 50 | 
            +
                expect(screen.queryAllByTestId("image-preview")).toHaveLength(0);
         | 
| 51 | 
            +
             | 
| 52 | 
            +
                await user.upload(input, file);
         | 
| 53 | 
            +
                expect(screen.queryAllByTestId("image-preview")).toHaveLength(1);
         | 
| 54 | 
            +
             | 
| 55 | 
            +
                const files = [
         | 
| 56 | 
            +
                  new File(["(⌐□_□)"], "chucknorris2.png", { type: "image/png" }),
         | 
| 57 | 
            +
                  new File(["(⌐□_□)"], "chucknorris3.png", { type: "image/png" }),
         | 
| 58 | 
            +
                ];
         | 
| 59 | 
            +
             | 
| 60 | 
            +
                await user.upload(input, files);
         | 
| 61 | 
            +
                expect(screen.queryAllByTestId("image-preview")).toHaveLength(3);
         | 
| 62 | 
            +
              });
         | 
| 63 | 
            +
             | 
| 64 | 
            +
              it("should remove the image preview when the close button is clicked", async () => {
         | 
| 65 | 
            +
                const user = userEvent.setup();
         | 
| 66 | 
            +
                render(<InteractiveChatBox onSubmit={onSubmitMock} onStop={onStopMock} />);
         | 
| 67 | 
            +
             | 
| 68 | 
            +
                const file = new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" });
         | 
| 69 | 
            +
                const input = screen.getByTestId("upload-image-input");
         | 
| 70 | 
            +
             | 
| 71 | 
            +
                await user.upload(input, file);
         | 
| 72 | 
            +
                expect(screen.queryAllByTestId("image-preview")).toHaveLength(1);
         | 
| 73 | 
            +
             | 
| 74 | 
            +
                const imagePreview = screen.getByTestId("image-preview");
         | 
| 75 | 
            +
                const closeButton = within(imagePreview).getByRole("button");
         | 
| 76 | 
            +
                await user.click(closeButton);
         | 
| 77 | 
            +
             | 
| 78 | 
            +
                expect(screen.queryAllByTestId("image-preview")).toHaveLength(0);
         | 
| 79 | 
            +
              });
         | 
| 80 | 
            +
             | 
| 81 | 
            +
              it("should call onSubmit with the message and images", async () => {
         | 
| 82 | 
            +
                const user = userEvent.setup();
         | 
| 83 | 
            +
                render(<InteractiveChatBox onSubmit={onSubmitMock} onStop={onStopMock} />);
         | 
| 84 | 
            +
             | 
| 85 | 
            +
                const textarea = within(screen.getByTestId("chat-input")).getByRole(
         | 
| 86 | 
            +
                  "textbox",
         | 
| 87 | 
            +
                );
         | 
| 88 | 
            +
                const input = screen.getByTestId("upload-image-input");
         | 
| 89 | 
            +
                const file = new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" });
         | 
| 90 | 
            +
             | 
| 91 | 
            +
                await user.upload(input, file);
         | 
| 92 | 
            +
                await user.type(textarea, "Hello, world!");
         | 
| 93 | 
            +
                await user.keyboard("{Enter}");
         | 
| 94 | 
            +
             | 
| 95 | 
            +
                expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!", [file]);
         | 
| 96 | 
            +
             | 
| 97 | 
            +
                // clear images after submission
         | 
| 98 | 
            +
                expect(screen.queryAllByTestId("image-preview")).toHaveLength(0);
         | 
| 99 | 
            +
              });
         | 
| 100 | 
            +
             | 
| 101 | 
            +
              it("should disable the submit button", async () => {
         | 
| 102 | 
            +
                const user = userEvent.setup();
         | 
| 103 | 
            +
                render(
         | 
| 104 | 
            +
                  <InteractiveChatBox
         | 
| 105 | 
            +
                    isDisabled
         | 
| 106 | 
            +
                    onSubmit={onSubmitMock}
         | 
| 107 | 
            +
                    onStop={onStopMock}
         | 
| 108 | 
            +
                  />,
         | 
| 109 | 
            +
                );
         | 
| 110 | 
            +
             | 
| 111 | 
            +
                const button = screen.getByRole("button");
         | 
| 112 | 
            +
                expect(button).toBeDisabled();
         | 
| 113 | 
            +
             | 
| 114 | 
            +
                await user.click(button);
         | 
| 115 | 
            +
                expect(onSubmitMock).not.toHaveBeenCalled();
         | 
| 116 | 
            +
              });
         | 
| 117 | 
            +
             | 
| 118 | 
            +
              it("should display the stop button if set and call onStop when clicked", async () => {
         | 
| 119 | 
            +
                const user = userEvent.setup();
         | 
| 120 | 
            +
                render(
         | 
| 121 | 
            +
                  <InteractiveChatBox
         | 
| 122 | 
            +
                    mode="stop"
         | 
| 123 | 
            +
                    onSubmit={onSubmitMock}
         | 
| 124 | 
            +
                    onStop={onStopMock}
         | 
| 125 | 
            +
                  />,
         | 
| 126 | 
            +
                );
         | 
| 127 | 
            +
             | 
| 128 | 
            +
                const stopButton = screen.getByTestId("stop-button");
         | 
| 129 | 
            +
                expect(stopButton).toBeInTheDocument();
         | 
| 130 | 
            +
             | 
| 131 | 
            +
                await user.click(stopButton);
         | 
| 132 | 
            +
                expect(onStopMock).toHaveBeenCalledOnce();
         | 
| 133 | 
            +
              });
         | 
| 134 | 
            +
             | 
| 135 | 
            +
              it("should handle image upload and message submission correctly", async () => {
         | 
| 136 | 
            +
                const user = userEvent.setup();
         | 
| 137 | 
            +
                const onSubmit = vi.fn();
         | 
| 138 | 
            +
                const onStop = vi.fn();
         | 
| 139 | 
            +
                const onChange = vi.fn();
         | 
| 140 | 
            +
             | 
| 141 | 
            +
                const { rerender } = render(
         | 
| 142 | 
            +
                  <InteractiveChatBox
         | 
| 143 | 
            +
                    onSubmit={onSubmit}
         | 
| 144 | 
            +
                    onStop={onStop}
         | 
| 145 | 
            +
                    onChange={onChange}
         | 
| 146 | 
            +
                    value="test message"
         | 
| 147 | 
            +
                  />
         | 
| 148 | 
            +
                );
         | 
| 149 | 
            +
             | 
| 150 | 
            +
                // Upload an image via the upload button - this should NOT clear the text input
         | 
| 151 | 
            +
                const file = new File(["dummy content"], "test.png", { type: "image/png" });
         | 
| 152 | 
            +
                const input = screen.getByTestId("upload-image-input");
         | 
| 153 | 
            +
                await user.upload(input, file);
         | 
| 154 | 
            +
             | 
| 155 | 
            +
                // Verify text input was not cleared
         | 
| 156 | 
            +
                expect(screen.getByRole("textbox")).toHaveValue("test message");
         | 
| 157 | 
            +
                expect(onChange).not.toHaveBeenCalledWith("");
         | 
| 158 | 
            +
             | 
| 159 | 
            +
                // Submit the message with image
         | 
| 160 | 
            +
                const submitButton = screen.getByRole("button", { name: "BUTTON$SEND" });
         | 
| 161 | 
            +
                await user.click(submitButton);
         | 
| 162 | 
            +
             | 
| 163 | 
            +
                // Verify onSubmit was called with the message and image
         | 
| 164 | 
            +
                expect(onSubmit).toHaveBeenCalledWith("test message", [file]);
         | 
| 165 | 
            +
             | 
| 166 | 
            +
                // Verify onChange was called to clear the text input
         | 
| 167 | 
            +
                expect(onChange).toHaveBeenCalledWith("");
         | 
| 168 | 
            +
             | 
| 169 | 
            +
                // Simulate parent component updating the value prop
         | 
| 170 | 
            +
                rerender(
         | 
| 171 | 
            +
                  <InteractiveChatBox
         | 
| 172 | 
            +
                    onSubmit={onSubmit}
         | 
| 173 | 
            +
                    onStop={onStop}
         | 
| 174 | 
            +
                    onChange={onChange}
         | 
| 175 | 
            +
                    value=""
         | 
| 176 | 
            +
                  />
         | 
| 177 | 
            +
                );
         | 
| 178 | 
            +
             | 
| 179 | 
            +
                // Verify the text input was cleared
         | 
| 180 | 
            +
                expect(screen.getByRole("textbox")).toHaveValue("");
         | 
| 181 | 
            +
             | 
| 182 | 
            +
                // Upload another image - this should NOT clear the text input
         | 
| 183 | 
            +
                onChange.mockClear();
         | 
| 184 | 
            +
                await user.upload(input, file);
         | 
| 185 | 
            +
             | 
| 186 | 
            +
                // Verify text input is still empty and onChange was not called
         | 
| 187 | 
            +
                expect(screen.getByRole("textbox")).toHaveValue("");
         | 
| 188 | 
            +
                expect(onChange).not.toHaveBeenCalled();
         | 
| 189 | 
            +
              });
         | 
| 190 | 
            +
            });
         | 
    	
        frontend/__tests__/components/jupyter/jupyter.test.tsx
    ADDED
    
    | @@ -0,0 +1,45 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { render, screen } from "@testing-library/react";
         | 
| 2 | 
            +
            import { Provider } from "react-redux";
         | 
| 3 | 
            +
            import { configureStore } from "@reduxjs/toolkit";
         | 
| 4 | 
            +
            import { JupyterEditor } from "#/components/features/jupyter/jupyter";
         | 
| 5 | 
            +
            import { jupyterReducer } from "#/state/jupyter-slice";
         | 
| 6 | 
            +
            import { vi, describe, it, expect } from "vitest";
         | 
| 7 | 
            +
             | 
| 8 | 
            +
            describe("JupyterEditor", () => {
         | 
| 9 | 
            +
              const mockStore = configureStore({
         | 
| 10 | 
            +
                reducer: {
         | 
| 11 | 
            +
                  fileState: () => ({}),
         | 
| 12 | 
            +
                  initalQuery: () => ({}),
         | 
| 13 | 
            +
                  browser: () => ({}),
         | 
| 14 | 
            +
                  chat: () => ({}),
         | 
| 15 | 
            +
                  code: () => ({}),
         | 
| 16 | 
            +
                  cmd: () => ({}),
         | 
| 17 | 
            +
                  agent: () => ({}),
         | 
| 18 | 
            +
                  jupyter: jupyterReducer,
         | 
| 19 | 
            +
                  securityAnalyzer: () => ({}),
         | 
| 20 | 
            +
                  status: () => ({}),
         | 
| 21 | 
            +
                },
         | 
| 22 | 
            +
                preloadedState: {
         | 
| 23 | 
            +
                  jupyter: {
         | 
| 24 | 
            +
                    cells: Array(20).fill({
         | 
| 25 | 
            +
                      content: "Test cell content",
         | 
| 26 | 
            +
                      type: "input",
         | 
| 27 | 
            +
                      output: "Test output",
         | 
| 28 | 
            +
                    }),
         | 
| 29 | 
            +
                  },
         | 
| 30 | 
            +
                },
         | 
| 31 | 
            +
              });
         | 
| 32 | 
            +
             | 
| 33 | 
            +
              it("should have a scrollable container", () => {
         | 
| 34 | 
            +
                render(
         | 
| 35 | 
            +
                  <Provider store={mockStore}>
         | 
| 36 | 
            +
                    <div style={{ height: "100vh" }}>
         | 
| 37 | 
            +
                      <JupyterEditor maxWidth={800} />
         | 
| 38 | 
            +
                    </div>
         | 
| 39 | 
            +
                  </Provider>
         | 
| 40 | 
            +
                );
         | 
| 41 | 
            +
             | 
| 42 | 
            +
                const container = screen.getByTestId("jupyter-container");
         | 
| 43 | 
            +
                expect(container).toHaveClass("flex-1 overflow-y-auto");
         | 
| 44 | 
            +
              });
         | 
| 45 | 
            +
            });
         | 
    	
        frontend/__tests__/components/landing-translations.test.tsx
    ADDED
    
    | @@ -0,0 +1,190 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { render, screen } from "@testing-library/react";
         | 
| 2 | 
            +
            import { test, expect, describe, vi } from "vitest";
         | 
| 3 | 
            +
            import { useTranslation } from "react-i18next";
         | 
| 4 | 
            +
            import translations from "../../src/i18n/translation.json";
         | 
| 5 | 
            +
            import { UserAvatar } from "../../src/components/features/sidebar/user-avatar";
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            vi.mock("@heroui/react", () => ({
         | 
| 8 | 
            +
              Tooltip: ({ content, children }: { content: string; children: React.ReactNode }) => (
         | 
| 9 | 
            +
                <div>
         | 
| 10 | 
            +
                  {children}
         | 
| 11 | 
            +
                  <div>{content}</div>
         | 
| 12 | 
            +
                </div>
         | 
| 13 | 
            +
              ),
         | 
| 14 | 
            +
            }));
         | 
| 15 | 
            +
             | 
| 16 | 
            +
            const supportedLanguages = ['en', 'ja', 'zh-CN', 'zh-TW', 'ko-KR', 'de', 'no', 'it', 'pt', 'es', 'ar', 'fr', 'tr'];
         | 
| 17 | 
            +
             | 
| 18 | 
            +
            // Helper function to check if a translation exists for all supported languages
         | 
| 19 | 
            +
            function checkTranslationExists(key: string) {
         | 
| 20 | 
            +
              const missingTranslations: string[] = [];
         | 
| 21 | 
            +
             | 
| 22 | 
            +
              const translationEntry = (translations as Record<string, Record<string, string>>)[key];
         | 
| 23 | 
            +
              if (!translationEntry) {
         | 
| 24 | 
            +
                throw new Error(`Translation key "${key}" does not exist in translation.json`);
         | 
| 25 | 
            +
              }
         | 
| 26 | 
            +
             | 
| 27 | 
            +
              for (const lang of supportedLanguages) {
         | 
| 28 | 
            +
                if (!translationEntry[lang]) {
         | 
| 29 | 
            +
                  missingTranslations.push(lang);
         | 
| 30 | 
            +
                }
         | 
| 31 | 
            +
              }
         | 
| 32 | 
            +
             | 
| 33 | 
            +
              return missingTranslations;
         | 
| 34 | 
            +
            }
         | 
| 35 | 
            +
             | 
| 36 | 
            +
            // Helper function to find duplicate translation keys
         | 
| 37 | 
            +
            function findDuplicateKeys(obj: Record<string, any>) {
         | 
| 38 | 
            +
              const seen = new Set<string>();
         | 
| 39 | 
            +
              const duplicates = new Set<string>();
         | 
| 40 | 
            +
             | 
| 41 | 
            +
              // Only check top-level keys as these are our translation keys
         | 
| 42 | 
            +
              for (const key in obj) {
         | 
| 43 | 
            +
                if (seen.has(key)) {
         | 
| 44 | 
            +
                  duplicates.add(key);
         | 
| 45 | 
            +
                } else {
         | 
| 46 | 
            +
                  seen.add(key);
         | 
| 47 | 
            +
                }
         | 
| 48 | 
            +
              }
         | 
| 49 | 
            +
             | 
| 50 | 
            +
              return Array.from(duplicates);
         | 
| 51 | 
            +
            }
         | 
| 52 | 
            +
             | 
| 53 | 
            +
            vi.mock("react-i18next", () => ({
         | 
| 54 | 
            +
              useTranslation: () => ({
         | 
| 55 | 
            +
                t: (key: string) => {
         | 
| 56 | 
            +
                  const translationEntry = (translations as Record<string, Record<string, string>>)[key];
         | 
| 57 | 
            +
                  return translationEntry?.ja || key;
         | 
| 58 | 
            +
                },
         | 
| 59 | 
            +
              }),
         | 
| 60 | 
            +
            }));
         | 
| 61 | 
            +
             | 
| 62 | 
            +
            describe("Landing page translations", () => {
         | 
| 63 | 
            +
              test("should render Japanese translations correctly", () => {
         | 
| 64 | 
            +
                // Mock a simple component that uses the translations
         | 
| 65 | 
            +
                const TestComponent = () => {
         | 
| 66 | 
            +
                  const { t } = useTranslation();
         | 
| 67 | 
            +
                  return (
         | 
| 68 | 
            +
                    <div>
         | 
| 69 | 
            +
                      <UserAvatar onClick={() => {}} />
         | 
| 70 | 
            +
                      <div data-testid="main-content">
         | 
| 71 | 
            +
                        <h1>{t("LANDING$TITLE")}</h1>
         | 
| 72 | 
            +
                        <button>{t("VSCODE$OPEN")}</button>
         | 
| 73 | 
            +
                        <button>{t("SUGGESTIONS$INCREASE_TEST_COVERAGE")}</button>
         | 
| 74 | 
            +
                        <button>{t("SUGGESTIONS$AUTO_MERGE_PRS")}</button>
         | 
| 75 | 
            +
                        <button>{t("SUGGESTIONS$FIX_README")}</button>
         | 
| 76 | 
            +
                        <button>{t("SUGGESTIONS$CLEAN_DEPENDENCIES")}</button>
         | 
| 77 | 
            +
                      </div>
         | 
| 78 | 
            +
                      <div data-testid="tabs">
         | 
| 79 | 
            +
                        <span>{t("WORKSPACE$TERMINAL_TAB_LABEL")}</span>
         | 
| 80 | 
            +
                        <span>{t("WORKSPACE$BROWSER_TAB_LABEL")}</span>
         | 
| 81 | 
            +
                        <span>{t("WORKSPACE$JUPYTER_TAB_LABEL")}</span>
         | 
| 82 | 
            +
                        <span>{t("WORKSPACE$CODE_EDITOR_TAB_LABEL")}</span>
         | 
| 83 | 
            +
                      </div>
         | 
| 84 | 
            +
                      <div data-testid="workspace-label">{t("WORKSPACE$TITLE")}</div>
         | 
| 85 | 
            +
                      <button data-testid="new-project">{t("PROJECT$NEW_PROJECT")}</button>
         | 
| 86 | 
            +
                      <div data-testid="status">
         | 
| 87 | 
            +
                        <span>{t("TERMINAL$WAITING_FOR_CLIENT")}</span>
         | 
| 88 | 
            +
                        <span>{t("STATUS$CONNECTED")}</span>
         | 
| 89 | 
            +
                        <span>{t("STATUS$CONNECTED_TO_SERVER")}</span>
         | 
| 90 | 
            +
                      </div>
         | 
| 91 | 
            +
                      <div data-testid="time">
         | 
| 92 | 
            +
                        <span>{`5 ${t("TIME$MINUTES_AGO")}`}</span>
         | 
| 93 | 
            +
                        <span>{`2 ${t("TIME$HOURS_AGO")}`}</span>
         | 
| 94 | 
            +
                        <span>{`3 ${t("TIME$DAYS_AGO")}`}</span>
         | 
| 95 | 
            +
                      </div>
         | 
| 96 | 
            +
                    </div>
         | 
| 97 | 
            +
                  );
         | 
| 98 | 
            +
                };
         | 
| 99 | 
            +
             | 
| 100 | 
            +
                render(<TestComponent />);
         | 
| 101 | 
            +
             | 
| 102 | 
            +
                // Check main content translations
         | 
| 103 | 
            +
                expect(screen.getByText("開発を始めましょう!")).toBeInTheDocument();
         | 
| 104 | 
            +
                expect(screen.getByText("VS Codeで開く")).toBeInTheDocument();
         | 
| 105 | 
            +
                expect(screen.getByText("テストカバレッジを向上させる")).toBeInTheDocument();
         | 
| 106 | 
            +
                expect(screen.getByText("Dependabot PRを自動マージ")).toBeInTheDocument();
         | 
| 107 | 
            +
                expect(screen.getByText("READMEを改善")).toBeInTheDocument();
         | 
| 108 | 
            +
                expect(screen.getByText("依存関係を整理")).toBeInTheDocument();
         | 
| 109 | 
            +
             | 
| 110 | 
            +
                // Check user avatar tooltip
         | 
| 111 | 
            +
                const userAvatar = screen.getByTestId("user-avatar");
         | 
| 112 | 
            +
                userAvatar.focus();
         | 
| 113 | 
            +
                expect(screen.getByText("アカウント設定")).toBeInTheDocument();
         | 
| 114 | 
            +
             | 
| 115 | 
            +
                // Check tab labels
         | 
| 116 | 
            +
                const tabs = screen.getByTestId("tabs");
         | 
| 117 | 
            +
                expect(tabs).toHaveTextContent("ターミナル");
         | 
| 118 | 
            +
                expect(tabs).toHaveTextContent("ブラウザ");
         | 
| 119 | 
            +
                expect(tabs).toHaveTextContent("Jupyter");
         | 
| 120 | 
            +
                expect(tabs).toHaveTextContent("コードエディタ");
         | 
| 121 | 
            +
             | 
| 122 | 
            +
                // Check workspace label and new project button
         | 
| 123 | 
            +
                expect(screen.getByTestId("workspace-label")).toHaveTextContent("ワークスペース");
         | 
| 124 | 
            +
                expect(screen.getByTestId("new-project")).toHaveTextContent("新規プロジェクト");
         | 
| 125 | 
            +
             | 
| 126 | 
            +
                // Check status messages
         | 
| 127 | 
            +
                const status = screen.getByTestId("status");
         | 
| 128 | 
            +
                expect(status).toHaveTextContent("クライアントの準備を待機中");
         | 
| 129 | 
            +
                expect(status).toHaveTextContent("接続済み");
         | 
| 130 | 
            +
                expect(status).toHaveTextContent("サー��ーに接続済み");
         | 
| 131 | 
            +
             | 
| 132 | 
            +
                // Check account settings menu
         | 
| 133 | 
            +
                expect(screen.getByText("アカウント設定")).toBeInTheDocument();
         | 
| 134 | 
            +
             | 
| 135 | 
            +
                // Check time-related translations
         | 
| 136 | 
            +
                const time = screen.getByTestId("time");
         | 
| 137 | 
            +
                expect(time).toHaveTextContent("5 分前");
         | 
| 138 | 
            +
                expect(time).toHaveTextContent("2 時間前");
         | 
| 139 | 
            +
                expect(time).toHaveTextContent("3 日前");
         | 
| 140 | 
            +
              });
         | 
| 141 | 
            +
             | 
| 142 | 
            +
              test("all translation keys should have translations for all supported languages", () => {
         | 
| 143 | 
            +
                // Test all translation keys used in the component
         | 
| 144 | 
            +
                const translationKeys = [
         | 
| 145 | 
            +
                  "LANDING$TITLE",
         | 
| 146 | 
            +
                  "VSCODE$OPEN",
         | 
| 147 | 
            +
                  "SUGGESTIONS$INCREASE_TEST_COVERAGE",
         | 
| 148 | 
            +
                  "SUGGESTIONS$AUTO_MERGE_PRS",
         | 
| 149 | 
            +
                  "SUGGESTIONS$FIX_README",
         | 
| 150 | 
            +
                  "SUGGESTIONS$CLEAN_DEPENDENCIES",
         | 
| 151 | 
            +
                  "WORKSPACE$TERMINAL_TAB_LABEL",
         | 
| 152 | 
            +
                  "WORKSPACE$BROWSER_TAB_LABEL",
         | 
| 153 | 
            +
                  "WORKSPACE$JUPYTER_TAB_LABEL",
         | 
| 154 | 
            +
                  "WORKSPACE$CODE_EDITOR_TAB_LABEL",
         | 
| 155 | 
            +
                  "WORKSPACE$TITLE",
         | 
| 156 | 
            +
                  "PROJECT$NEW_PROJECT",
         | 
| 157 | 
            +
                  "TERMINAL$WAITING_FOR_CLIENT",
         | 
| 158 | 
            +
                  "STATUS$CONNECTED",
         | 
| 159 | 
            +
                  "STATUS$CONNECTED_TO_SERVER",
         | 
| 160 | 
            +
                  "TIME$MINUTES_AGO",
         | 
| 161 | 
            +
                  "TIME$HOURS_AGO",
         | 
| 162 | 
            +
                  "TIME$DAYS_AGO"
         | 
| 163 | 
            +
                ];
         | 
| 164 | 
            +
             | 
| 165 | 
            +
                // Check all keys and collect missing translations
         | 
| 166 | 
            +
                const missingTranslationsMap = new Map<string, string[]>();
         | 
| 167 | 
            +
                translationKeys.forEach(key => {
         | 
| 168 | 
            +
                  const missing = checkTranslationExists(key);
         | 
| 169 | 
            +
                  if (missing.length > 0) {
         | 
| 170 | 
            +
                    missingTranslationsMap.set(key, missing);
         | 
| 171 | 
            +
                  }
         | 
| 172 | 
            +
                });
         | 
| 173 | 
            +
             | 
| 174 | 
            +
                // If any translations are missing, throw an error with all missing translations
         | 
| 175 | 
            +
                if (missingTranslationsMap.size > 0) {
         | 
| 176 | 
            +
                  const errorMessage = Array.from(missingTranslationsMap.entries())
         | 
| 177 | 
            +
                    .map(([key, langs]) => `\n- "${key}" is missing translations for: ${langs.join(', ')}`)
         | 
| 178 | 
            +
                    .join('');
         | 
| 179 | 
            +
                  throw new Error(`Missing translations:${errorMessage}`);
         | 
| 180 | 
            +
                }
         | 
| 181 | 
            +
              });
         | 
| 182 | 
            +
             | 
| 183 | 
            +
              test("translation file should not have duplicate keys", () => {
         | 
| 184 | 
            +
                const duplicates = findDuplicateKeys(translations);
         | 
| 185 | 
            +
             | 
| 186 | 
            +
                if (duplicates.length > 0) {
         | 
| 187 | 
            +
                  throw new Error(`Found duplicate translation keys: ${duplicates.join(', ')}`);
         | 
| 188 | 
            +
                }
         | 
| 189 | 
            +
              });
         | 
| 190 | 
            +
            });
         | 
    	
        frontend/__tests__/components/modals/base-modal/base-modal.test.tsx
    ADDED
    
    | @@ -0,0 +1,151 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { render, screen, act } from "@testing-library/react";
         | 
| 2 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 3 | 
            +
            import { describe, it, vi, expect } from "vitest";
         | 
| 4 | 
            +
            import { BaseModal } from "#/components/shared/modals/base-modal/base-modal";
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            describe("BaseModal", () => {
         | 
| 7 | 
            +
              const onOpenChangeMock = vi.fn();
         | 
| 8 | 
            +
             | 
| 9 | 
            +
              it("should render if the modal is open", () => {
         | 
| 10 | 
            +
                const { rerender } = render(
         | 
| 11 | 
            +
                  <BaseModal
         | 
| 12 | 
            +
                    isOpen={false}
         | 
| 13 | 
            +
                    onOpenChange={onOpenChangeMock}
         | 
| 14 | 
            +
                    title="Settings"
         | 
| 15 | 
            +
                  />,
         | 
| 16 | 
            +
                );
         | 
| 17 | 
            +
                expect(screen.queryByText("Settings")).not.toBeInTheDocument();
         | 
| 18 | 
            +
             | 
| 19 | 
            +
                rerender(
         | 
| 20 | 
            +
                  <BaseModal title="Settings" onOpenChange={onOpenChangeMock} isOpen />,
         | 
| 21 | 
            +
                );
         | 
| 22 | 
            +
                expect(screen.getByText("Settings")).toBeInTheDocument();
         | 
| 23 | 
            +
              });
         | 
| 24 | 
            +
             | 
| 25 | 
            +
              it("should render an optional subtitle", () => {
         | 
| 26 | 
            +
                render(
         | 
| 27 | 
            +
                  <BaseModal
         | 
| 28 | 
            +
                    isOpen
         | 
| 29 | 
            +
                    onOpenChange={onOpenChangeMock}
         | 
| 30 | 
            +
                    title="Settings"
         | 
| 31 | 
            +
                    subtitle="Subtitle"
         | 
| 32 | 
            +
                  />,
         | 
| 33 | 
            +
                );
         | 
| 34 | 
            +
                expect(screen.getByText("Subtitle")).toBeInTheDocument();
         | 
| 35 | 
            +
              });
         | 
| 36 | 
            +
             | 
| 37 | 
            +
              it("should render actions", async () => {
         | 
| 38 | 
            +
                const onPrimaryClickMock = vi.fn();
         | 
| 39 | 
            +
                const onSecondaryClickMock = vi.fn();
         | 
| 40 | 
            +
             | 
| 41 | 
            +
                const primaryAction = {
         | 
| 42 | 
            +
                  action: onPrimaryClickMock,
         | 
| 43 | 
            +
                  label: "Save",
         | 
| 44 | 
            +
                };
         | 
| 45 | 
            +
             | 
| 46 | 
            +
                const secondaryAction = {
         | 
| 47 | 
            +
                  action: onSecondaryClickMock,
         | 
| 48 | 
            +
                  label: "Cancel",
         | 
| 49 | 
            +
                };
         | 
| 50 | 
            +
             | 
| 51 | 
            +
                render(
         | 
| 52 | 
            +
                  <BaseModal
         | 
| 53 | 
            +
                    isOpen
         | 
| 54 | 
            +
                    onOpenChange={onOpenChangeMock}
         | 
| 55 | 
            +
                    title="Settings"
         | 
| 56 | 
            +
                    actions={[primaryAction, secondaryAction]}
         | 
| 57 | 
            +
                  />,
         | 
| 58 | 
            +
                );
         | 
| 59 | 
            +
             | 
| 60 | 
            +
                expect(screen.getByText("Save")).toBeInTheDocument();
         | 
| 61 | 
            +
                expect(screen.getByText("Cancel")).toBeInTheDocument();
         | 
| 62 | 
            +
             | 
| 63 | 
            +
                await userEvent.click(screen.getByText("Save"));
         | 
| 64 | 
            +
                expect(onPrimaryClickMock).toHaveBeenCalledTimes(1);
         | 
| 65 | 
            +
             | 
| 66 | 
            +
                await userEvent.click(screen.getByText("Cancel"));
         | 
| 67 | 
            +
                expect(onSecondaryClickMock).toHaveBeenCalledTimes(1);
         | 
| 68 | 
            +
              });
         | 
| 69 | 
            +
             | 
| 70 | 
            +
              it("should close the modal after an action is performed", async () => {
         | 
| 71 | 
            +
                render(
         | 
| 72 | 
            +
                  <BaseModal
         | 
| 73 | 
            +
                    isOpen
         | 
| 74 | 
            +
                    onOpenChange={onOpenChangeMock}
         | 
| 75 | 
            +
                    title="Settings"
         | 
| 76 | 
            +
                    actions={[
         | 
| 77 | 
            +
                      {
         | 
| 78 | 
            +
                        label: "Save",
         | 
| 79 | 
            +
                        action: () => {},
         | 
| 80 | 
            +
                        closeAfterAction: true,
         | 
| 81 | 
            +
                      },
         | 
| 82 | 
            +
                    ]}
         | 
| 83 | 
            +
                  />,
         | 
| 84 | 
            +
                );
         | 
| 85 | 
            +
             | 
| 86 | 
            +
                await userEvent.click(screen.getByText("Save"));
         | 
| 87 | 
            +
                expect(onOpenChangeMock).toHaveBeenCalledTimes(1);
         | 
| 88 | 
            +
              });
         | 
| 89 | 
            +
             | 
| 90 | 
            +
              it("should render children", () => {
         | 
| 91 | 
            +
                render(
         | 
| 92 | 
            +
                  <BaseModal isOpen onOpenChange={onOpenChangeMock} title="Settings">
         | 
| 93 | 
            +
                    <div>Children</div>
         | 
| 94 | 
            +
                  </BaseModal>,
         | 
| 95 | 
            +
                );
         | 
| 96 | 
            +
                expect(screen.getByText("Children")).toBeInTheDocument();
         | 
| 97 | 
            +
              });
         | 
| 98 | 
            +
             | 
| 99 | 
            +
              it("should disable the action given the condition", () => {
         | 
| 100 | 
            +
                const { rerender } = render(
         | 
| 101 | 
            +
                  <BaseModal
         | 
| 102 | 
            +
                    isOpen
         | 
| 103 | 
            +
                    onOpenChange={onOpenChangeMock}
         | 
| 104 | 
            +
                    title="Settings"
         | 
| 105 | 
            +
                    actions={[
         | 
| 106 | 
            +
                      {
         | 
| 107 | 
            +
                        label: "Save",
         | 
| 108 | 
            +
                        action: () => {},
         | 
| 109 | 
            +
                        isDisabled: true,
         | 
| 110 | 
            +
                      },
         | 
| 111 | 
            +
                    ]}
         | 
| 112 | 
            +
                  />,
         | 
| 113 | 
            +
                );
         | 
| 114 | 
            +
             | 
| 115 | 
            +
                expect(screen.getByText("Save")).toBeDisabled();
         | 
| 116 | 
            +
             | 
| 117 | 
            +
                rerender(
         | 
| 118 | 
            +
                  <BaseModal
         | 
| 119 | 
            +
                    isOpen
         | 
| 120 | 
            +
                    onOpenChange={onOpenChangeMock}
         | 
| 121 | 
            +
                    title="Settings"
         | 
| 122 | 
            +
                    actions={[
         | 
| 123 | 
            +
                      {
         | 
| 124 | 
            +
                        label: "Save",
         | 
| 125 | 
            +
                        action: () => {},
         | 
| 126 | 
            +
                        isDisabled: false,
         | 
| 127 | 
            +
                      },
         | 
| 128 | 
            +
                    ]}
         | 
| 129 | 
            +
                  />,
         | 
| 130 | 
            +
                );
         | 
| 131 | 
            +
             | 
| 132 | 
            +
                expect(screen.getByText("Save")).not.toBeDisabled();
         | 
| 133 | 
            +
              });
         | 
| 134 | 
            +
             | 
| 135 | 
            +
              it.skip("should not close if the backdrop or escape key is pressed", () => {
         | 
| 136 | 
            +
                render(
         | 
| 137 | 
            +
                  <BaseModal
         | 
| 138 | 
            +
                    isOpen
         | 
| 139 | 
            +
                    onOpenChange={onOpenChangeMock}
         | 
| 140 | 
            +
                    title="Settings"
         | 
| 141 | 
            +
                    isDismissable={false}
         | 
| 142 | 
            +
                  />,
         | 
| 143 | 
            +
                );
         | 
| 144 | 
            +
             | 
| 145 | 
            +
                act(() => {
         | 
| 146 | 
            +
                  userEvent.keyboard("{esc}");
         | 
| 147 | 
            +
                });
         | 
| 148 | 
            +
                // fails because the nextui component wraps the modal content in an aria-hidden div
         | 
| 149 | 
            +
                expect(screen.getByRole("dialog")).toBeVisible();
         | 
| 150 | 
            +
              });
         | 
| 151 | 
            +
            });
         | 
    	
        frontend/__tests__/components/modals/settings/model-selector.test.tsx
    ADDED
    
    | @@ -0,0 +1,136 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { describe, it, expect, vi } from "vitest";
         | 
| 2 | 
            +
            import { render, screen } from "@testing-library/react";
         | 
| 3 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 4 | 
            +
            import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            // Mock react-i18next
         | 
| 7 | 
            +
            vi.mock("react-i18next", () => ({
         | 
| 8 | 
            +
              useTranslation: () => ({
         | 
| 9 | 
            +
                t: (key: string) => {
         | 
| 10 | 
            +
                  const translations: { [key: string]: string } = {
         | 
| 11 | 
            +
                    LLM$PROVIDER: "LLM Provider",
         | 
| 12 | 
            +
                    LLM$MODEL: "LLM Model",
         | 
| 13 | 
            +
                    LLM$SELECT_PROVIDER_PLACEHOLDER: "Select a provider",
         | 
| 14 | 
            +
                    LLM$SELECT_MODEL_PLACEHOLDER: "Select a model",
         | 
| 15 | 
            +
                  };
         | 
| 16 | 
            +
                  return translations[key] || key;
         | 
| 17 | 
            +
                },
         | 
| 18 | 
            +
              }),
         | 
| 19 | 
            +
            }));
         | 
| 20 | 
            +
             | 
| 21 | 
            +
            describe("ModelSelector", () => {
         | 
| 22 | 
            +
              const models = {
         | 
| 23 | 
            +
                openai: {
         | 
| 24 | 
            +
                  separator: "/",
         | 
| 25 | 
            +
                  models: ["gpt-4o", "gpt-4o-mini"],
         | 
| 26 | 
            +
                },
         | 
| 27 | 
            +
                azure: {
         | 
| 28 | 
            +
                  separator: "/",
         | 
| 29 | 
            +
                  models: ["ada", "gpt-35-turbo"],
         | 
| 30 | 
            +
                },
         | 
| 31 | 
            +
                vertex_ai: {
         | 
| 32 | 
            +
                  separator: "/",
         | 
| 33 | 
            +
                  models: ["chat-bison", "chat-bison-32k"],
         | 
| 34 | 
            +
                },
         | 
| 35 | 
            +
                cohere: {
         | 
| 36 | 
            +
                  separator: ".",
         | 
| 37 | 
            +
                  models: ["command-r-v1:0"],
         | 
| 38 | 
            +
                },
         | 
| 39 | 
            +
              };
         | 
| 40 | 
            +
             | 
| 41 | 
            +
              it("should display the provider selector", async () => {
         | 
| 42 | 
            +
                const user = userEvent.setup();
         | 
| 43 | 
            +
                render(<ModelSelector models={models} />);
         | 
| 44 | 
            +
             | 
| 45 | 
            +
                const selector = screen.getByLabelText("LLM Provider");
         | 
| 46 | 
            +
                expect(selector).toBeInTheDocument();
         | 
| 47 | 
            +
             | 
| 48 | 
            +
                await user.click(selector);
         | 
| 49 | 
            +
             | 
| 50 | 
            +
                expect(screen.getByText("OpenAI")).toBeInTheDocument();
         | 
| 51 | 
            +
                expect(screen.getByText("Azure")).toBeInTheDocument();
         | 
| 52 | 
            +
                expect(screen.getByText("VertexAI")).toBeInTheDocument();
         | 
| 53 | 
            +
                expect(screen.getByText("cohere")).toBeInTheDocument();
         | 
| 54 | 
            +
              });
         | 
| 55 | 
            +
             | 
| 56 | 
            +
              it("should disable the model selector if the provider is not selected", async () => {
         | 
| 57 | 
            +
                const user = userEvent.setup();
         | 
| 58 | 
            +
                render(<ModelSelector models={models} />);
         | 
| 59 | 
            +
             | 
| 60 | 
            +
                const modelSelector = screen.getByLabelText("LLM Model");
         | 
| 61 | 
            +
                expect(modelSelector).toBeDisabled();
         | 
| 62 | 
            +
             | 
| 63 | 
            +
                const providerSelector = screen.getByLabelText("LLM Provider");
         | 
| 64 | 
            +
                await user.click(providerSelector);
         | 
| 65 | 
            +
             | 
| 66 | 
            +
                const vertexAI = screen.getByText("VertexAI");
         | 
| 67 | 
            +
                await user.click(vertexAI);
         | 
| 68 | 
            +
             | 
| 69 | 
            +
                expect(modelSelector).not.toBeDisabled();
         | 
| 70 | 
            +
              });
         | 
| 71 | 
            +
             | 
| 72 | 
            +
              it("should display the model selector", async () => {
         | 
| 73 | 
            +
                const user = userEvent.setup();
         | 
| 74 | 
            +
                render(<ModelSelector models={models} />);
         | 
| 75 | 
            +
             | 
| 76 | 
            +
                const providerSelector = screen.getByLabelText("LLM Provider");
         | 
| 77 | 
            +
                await user.click(providerSelector);
         | 
| 78 | 
            +
             | 
| 79 | 
            +
                const azureProvider = screen.getByText("Azure");
         | 
| 80 | 
            +
                await user.click(azureProvider);
         | 
| 81 | 
            +
             | 
| 82 | 
            +
                const modelSelector = screen.getByLabelText("LLM Model");
         | 
| 83 | 
            +
                await user.click(modelSelector);
         | 
| 84 | 
            +
             | 
| 85 | 
            +
                expect(screen.getByText("ada")).toBeInTheDocument();
         | 
| 86 | 
            +
                expect(screen.getByText("gpt-35-turbo")).toBeInTheDocument();
         | 
| 87 | 
            +
             | 
| 88 | 
            +
                await user.click(providerSelector);
         | 
| 89 | 
            +
                const vertexProvider = screen.getByText("VertexAI");
         | 
| 90 | 
            +
                await user.click(vertexProvider);
         | 
| 91 | 
            +
             | 
| 92 | 
            +
                await user.click(modelSelector);
         | 
| 93 | 
            +
             | 
| 94 | 
            +
                // Test fails when expecting these values to be present.
         | 
| 95 | 
            +
                // My hypothesis is that it has something to do with NextUI's
         | 
| 96 | 
            +
                // list virtualization
         | 
| 97 | 
            +
             | 
| 98 | 
            +
                // expect(screen.getByText("chat-bison")).toBeInTheDocument();
         | 
| 99 | 
            +
                // expect(screen.getByText("chat-bison-32k")).toBeInTheDocument();
         | 
| 100 | 
            +
              });
         | 
| 101 | 
            +
             | 
| 102 | 
            +
              it("should call onModelChange when the model is changed", async () => {
         | 
| 103 | 
            +
                const user = userEvent.setup();
         | 
| 104 | 
            +
                render(<ModelSelector models={models} />);
         | 
| 105 | 
            +
             | 
| 106 | 
            +
                const providerSelector = screen.getByLabelText("LLM Provider");
         | 
| 107 | 
            +
                const modelSelector = screen.getByLabelText("LLM Model");
         | 
| 108 | 
            +
             | 
| 109 | 
            +
                await user.click(providerSelector);
         | 
| 110 | 
            +
                await user.click(screen.getByText("Azure"));
         | 
| 111 | 
            +
             | 
| 112 | 
            +
                await user.click(modelSelector);
         | 
| 113 | 
            +
                await user.click(screen.getByText("ada"));
         | 
| 114 | 
            +
             | 
| 115 | 
            +
                await user.click(modelSelector);
         | 
| 116 | 
            +
                await user.click(screen.getByText("gpt-35-turbo"));
         | 
| 117 | 
            +
             | 
| 118 | 
            +
                await user.click(providerSelector);
         | 
| 119 | 
            +
                await user.click(screen.getByText("cohere"));
         | 
| 120 | 
            +
             | 
| 121 | 
            +
                await user.click(modelSelector);
         | 
| 122 | 
            +
             | 
| 123 | 
            +
                // Test fails when expecting this values to be present.
         | 
| 124 | 
            +
                // My hypothesis is that it has something to do with NextUI's
         | 
| 125 | 
            +
                // list virtualization
         | 
| 126 | 
            +
             | 
| 127 | 
            +
                // await user.click(screen.getByText("command-r-v1:0"));
         | 
| 128 | 
            +
              });
         | 
| 129 | 
            +
             | 
| 130 | 
            +
              it("should have a default value if passed", async () => {
         | 
| 131 | 
            +
                render(<ModelSelector models={models} currentModel="azure/ada" />);
         | 
| 132 | 
            +
             | 
| 133 | 
            +
                expect(screen.getByLabelText("LLM Provider")).toHaveValue("Azure");
         | 
| 134 | 
            +
                expect(screen.getByLabelText("LLM Model")).toHaveValue("ada");
         | 
| 135 | 
            +
              });
         | 
| 136 | 
            +
            });
         | 
    	
        frontend/__tests__/components/settings/settings-input.test.tsx
    ADDED
    
    | @@ -0,0 +1,109 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { render, screen } from "@testing-library/react";
         | 
| 2 | 
            +
            import { describe, expect, it, vi } from "vitest";
         | 
| 3 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 4 | 
            +
            import { SettingsInput } from "#/components/features/settings/settings-input";
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            describe("SettingsInput", () => {
         | 
| 7 | 
            +
              it("should render an optional tag if showOptionalTag is true", async () => {
         | 
| 8 | 
            +
                const { rerender } = render(
         | 
| 9 | 
            +
                  <SettingsInput testId="test-input" label="Test Input" type="text" />,
         | 
| 10 | 
            +
                );
         | 
| 11 | 
            +
             | 
| 12 | 
            +
                expect(screen.queryByText(/optional/i)).not.toBeInTheDocument();
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                rerender(
         | 
| 15 | 
            +
                  <SettingsInput
         | 
| 16 | 
            +
                    testId="test-input"
         | 
| 17 | 
            +
                    showOptionalTag
         | 
| 18 | 
            +
                    label="Test Input"
         | 
| 19 | 
            +
                    type="text"
         | 
| 20 | 
            +
                  />,
         | 
| 21 | 
            +
                );
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                expect(screen.getByText(/optional/i)).toBeInTheDocument();
         | 
| 24 | 
            +
              });
         | 
| 25 | 
            +
             | 
| 26 | 
            +
              it("should disable the input if isDisabled is true", async () => {
         | 
| 27 | 
            +
                const { rerender } = render(
         | 
| 28 | 
            +
                  <SettingsInput testId="test-input" label="Test Input" type="text" />,
         | 
| 29 | 
            +
                );
         | 
| 30 | 
            +
             | 
| 31 | 
            +
                expect(screen.getByTestId("test-input")).toBeEnabled();
         | 
| 32 | 
            +
             | 
| 33 | 
            +
                rerender(
         | 
| 34 | 
            +
                  <SettingsInput
         | 
| 35 | 
            +
                    testId="test-input"
         | 
| 36 | 
            +
                    label="Test Input"
         | 
| 37 | 
            +
                    type="text"
         | 
| 38 | 
            +
                    isDisabled
         | 
| 39 | 
            +
                  />,
         | 
| 40 | 
            +
                );
         | 
| 41 | 
            +
             | 
| 42 | 
            +
                expect(screen.getByTestId("test-input")).toBeDisabled();
         | 
| 43 | 
            +
              });
         | 
| 44 | 
            +
             | 
| 45 | 
            +
              it("should set a placeholder on the input", async () => {
         | 
| 46 | 
            +
                render(
         | 
| 47 | 
            +
                  <SettingsInput
         | 
| 48 | 
            +
                    testId="test-input"
         | 
| 49 | 
            +
                    label="Test Input"
         | 
| 50 | 
            +
                    type="text"
         | 
| 51 | 
            +
                    placeholder="Test Placeholder"
         | 
| 52 | 
            +
                  />,
         | 
| 53 | 
            +
                );
         | 
| 54 | 
            +
             | 
| 55 | 
            +
                expect(screen.getByTestId("test-input")).toHaveAttribute(
         | 
| 56 | 
            +
                  "placeholder",
         | 
| 57 | 
            +
                  "Test Placeholder",
         | 
| 58 | 
            +
                );
         | 
| 59 | 
            +
              });
         | 
| 60 | 
            +
             | 
| 61 | 
            +
              it("should set a default value on the input", async () => {
         | 
| 62 | 
            +
                render(
         | 
| 63 | 
            +
                  <SettingsInput
         | 
| 64 | 
            +
                    testId="test-input"
         | 
| 65 | 
            +
                    label="Test Input"
         | 
| 66 | 
            +
                    type="text"
         | 
| 67 | 
            +
                    defaultValue="Test Value"
         | 
| 68 | 
            +
                  />,
         | 
| 69 | 
            +
                );
         | 
| 70 | 
            +
             | 
| 71 | 
            +
                expect(screen.getByTestId("test-input")).toHaveValue("Test Value");
         | 
| 72 | 
            +
              });
         | 
| 73 | 
            +
             | 
| 74 | 
            +
              it("should render start content", async () => {
         | 
| 75 | 
            +
                const startContent = <div>Start Content</div>;
         | 
| 76 | 
            +
             | 
| 77 | 
            +
                render(
         | 
| 78 | 
            +
                  <SettingsInput
         | 
| 79 | 
            +
                    testId="test-input"
         | 
| 80 | 
            +
                    label="Test Input"
         | 
| 81 | 
            +
                    type="text"
         | 
| 82 | 
            +
                    defaultValue="Test Value"
         | 
| 83 | 
            +
                    startContent={startContent}
         | 
| 84 | 
            +
                  />,
         | 
| 85 | 
            +
                );
         | 
| 86 | 
            +
             | 
| 87 | 
            +
                expect(screen.getByText("Start Content")).toBeInTheDocument();
         | 
| 88 | 
            +
              });
         | 
| 89 | 
            +
             | 
| 90 | 
            +
              it("should call onChange with the input value", async () => {
         | 
| 91 | 
            +
                const onChangeMock = vi.fn();
         | 
| 92 | 
            +
                const user = userEvent.setup();
         | 
| 93 | 
            +
             | 
| 94 | 
            +
                render(
         | 
| 95 | 
            +
                  <SettingsInput
         | 
| 96 | 
            +
                    testId="test-input"
         | 
| 97 | 
            +
                    label="Test Input"
         | 
| 98 | 
            +
                    type="text"
         | 
| 99 | 
            +
                    onChange={onChangeMock}
         | 
| 100 | 
            +
                  />,
         | 
| 101 | 
            +
                );
         | 
| 102 | 
            +
             | 
| 103 | 
            +
                const input = screen.getByTestId("test-input");
         | 
| 104 | 
            +
                await user.type(input, "Test");
         | 
| 105 | 
            +
             | 
| 106 | 
            +
                expect(onChangeMock).toHaveBeenCalledTimes(4);
         | 
| 107 | 
            +
                expect(onChangeMock).toHaveBeenNthCalledWith(4, "Test");
         | 
| 108 | 
            +
              });
         | 
| 109 | 
            +
            });
         | 
    	
        frontend/__tests__/components/settings/settings-switch.test.tsx
    ADDED
    
    | @@ -0,0 +1,64 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { render, screen } from "@testing-library/react";
         | 
| 2 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 3 | 
            +
            import { describe, expect, it, vi } from "vitest";
         | 
| 4 | 
            +
            import { SettingsSwitch } from "#/components/features/settings/settings-switch";
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            describe("SettingsSwitch", () => {
         | 
| 7 | 
            +
              it("should call the onChange handler when the input is clicked", async () => {
         | 
| 8 | 
            +
                const user = userEvent.setup();
         | 
| 9 | 
            +
                const onToggleMock = vi.fn();
         | 
| 10 | 
            +
                render(
         | 
| 11 | 
            +
                  <SettingsSwitch testId="test-switch" onToggle={onToggleMock}>
         | 
| 12 | 
            +
                    Test Switch
         | 
| 13 | 
            +
                  </SettingsSwitch>,
         | 
| 14 | 
            +
                );
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                const switchInput = screen.getByTestId("test-switch");
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                await user.click(switchInput);
         | 
| 19 | 
            +
                expect(onToggleMock).toHaveBeenCalledWith(true);
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                await user.click(switchInput);
         | 
| 22 | 
            +
                expect(onToggleMock).toHaveBeenCalledWith(false);
         | 
| 23 | 
            +
              });
         | 
| 24 | 
            +
             | 
| 25 | 
            +
              it("should render a beta tag if isBeta is true", () => {
         | 
| 26 | 
            +
                const { rerender } = render(
         | 
| 27 | 
            +
                  <SettingsSwitch testId="test-switch" onToggle={vi.fn()} isBeta={false}>
         | 
| 28 | 
            +
                    Test Switch
         | 
| 29 | 
            +
                  </SettingsSwitch>,
         | 
| 30 | 
            +
                );
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                expect(screen.queryByText(/beta/i)).not.toBeInTheDocument();
         | 
| 33 | 
            +
             | 
| 34 | 
            +
                rerender(
         | 
| 35 | 
            +
                  <SettingsSwitch testId="test-switch" onToggle={vi.fn()} isBeta>
         | 
| 36 | 
            +
                    Test Switch
         | 
| 37 | 
            +
                  </SettingsSwitch>,
         | 
| 38 | 
            +
                );
         | 
| 39 | 
            +
             | 
| 40 | 
            +
                expect(screen.getByText(/beta/i)).toBeInTheDocument();
         | 
| 41 | 
            +
              });
         | 
| 42 | 
            +
             | 
| 43 | 
            +
              it("should be able to set a default toggle state", async () => {
         | 
| 44 | 
            +
                const user = userEvent.setup();
         | 
| 45 | 
            +
                const onToggleMock = vi.fn();
         | 
| 46 | 
            +
                render(
         | 
| 47 | 
            +
                  <SettingsSwitch
         | 
| 48 | 
            +
                    testId="test-switch"
         | 
| 49 | 
            +
                    onToggle={onToggleMock}
         | 
| 50 | 
            +
                    defaultIsToggled
         | 
| 51 | 
            +
                  >
         | 
| 52 | 
            +
                    Test Switch
         | 
| 53 | 
            +
                  </SettingsSwitch>,
         | 
| 54 | 
            +
                );
         | 
| 55 | 
            +
             | 
| 56 | 
            +
                expect(screen.getByTestId("test-switch")).toBeChecked();
         | 
| 57 | 
            +
             | 
| 58 | 
            +
                const switchInput = screen.getByTestId("test-switch");
         | 
| 59 | 
            +
                await user.click(switchInput);
         | 
| 60 | 
            +
                expect(onToggleMock).toHaveBeenCalledWith(false);
         | 
| 61 | 
            +
             | 
| 62 | 
            +
                expect(screen.getByTestId("test-switch")).not.toBeChecked();
         | 
| 63 | 
            +
              });
         | 
| 64 | 
            +
            });
         | 
    	
        frontend/__tests__/components/shared/brand-button.test.tsx
    ADDED
    
    | @@ -0,0 +1,55 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { render, screen } from "@testing-library/react";
         | 
| 2 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 3 | 
            +
            import { describe, expect, it, vi } from "vitest";
         | 
| 4 | 
            +
            import { BrandButton } from "#/components/features/settings/brand-button";
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            describe("BrandButton", () => {
         | 
| 7 | 
            +
              const onClickMock = vi.fn();
         | 
| 8 | 
            +
             | 
| 9 | 
            +
              it("should set a test id", () => {
         | 
| 10 | 
            +
                render(
         | 
| 11 | 
            +
                  <BrandButton testId="brand-button" type="button" variant="primary">
         | 
| 12 | 
            +
                    Test Button
         | 
| 13 | 
            +
                  </BrandButton>,
         | 
| 14 | 
            +
                );
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                expect(screen.getByTestId("brand-button")).toBeInTheDocument();
         | 
| 17 | 
            +
              });
         | 
| 18 | 
            +
             | 
| 19 | 
            +
              it("should call onClick when clicked", async () => {
         | 
| 20 | 
            +
                const user = userEvent.setup();
         | 
| 21 | 
            +
                render(
         | 
| 22 | 
            +
                  <BrandButton type="button" variant="primary" onClick={onClickMock}>
         | 
| 23 | 
            +
                    Test Button
         | 
| 24 | 
            +
                  </BrandButton>,
         | 
| 25 | 
            +
                );
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                await user.click(screen.getByText("Test Button"));
         | 
| 28 | 
            +
              });
         | 
| 29 | 
            +
             | 
| 30 | 
            +
              it("should be disabled if isDisabled is true", () => {
         | 
| 31 | 
            +
                render(
         | 
| 32 | 
            +
                  <BrandButton type="button" variant="primary" isDisabled>
         | 
| 33 | 
            +
                    Test Button
         | 
| 34 | 
            +
                  </BrandButton>,
         | 
| 35 | 
            +
                );
         | 
| 36 | 
            +
             | 
| 37 | 
            +
                expect(screen.getByText("Test Button")).toBeDisabled();
         | 
| 38 | 
            +
              });
         | 
| 39 | 
            +
             | 
| 40 | 
            +
              it("should pass a start content", () => {
         | 
| 41 | 
            +
                render(
         | 
| 42 | 
            +
                  <BrandButton
         | 
| 43 | 
            +
                    type="button"
         | 
| 44 | 
            +
                    variant="primary"
         | 
| 45 | 
            +
                    startContent={
         | 
| 46 | 
            +
                      <div data-testid="custom-start-content">Start Content</div>
         | 
| 47 | 
            +
                    }
         | 
| 48 | 
            +
                  >
         | 
| 49 | 
            +
                    Test Button
         | 
| 50 | 
            +
                  </BrandButton>,
         | 
| 51 | 
            +
                );
         | 
| 52 | 
            +
             | 
| 53 | 
            +
                screen.getByTestId("custom-start-content");
         | 
| 54 | 
            +
              });
         | 
| 55 | 
            +
            });
         | 
    	
        frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx
    ADDED
    
    | @@ -0,0 +1,40 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 2 | 
            +
            import { describe, expect, it, vi } from "vitest";
         | 
| 3 | 
            +
            import { renderWithProviders } from "test-utils";
         | 
| 4 | 
            +
            import { createRoutesStub } from "react-router";
         | 
| 5 | 
            +
            import { screen } from "@testing-library/react";
         | 
| 6 | 
            +
            import OpenHands from "#/api/open-hands";
         | 
| 7 | 
            +
            import { SettingsForm } from "#/components/shared/modals/settings/settings-form";
         | 
| 8 | 
            +
            import { DEFAULT_SETTINGS } from "#/services/settings";
         | 
| 9 | 
            +
             | 
| 10 | 
            +
            describe("SettingsForm", () => {
         | 
| 11 | 
            +
              const onCloseMock = vi.fn();
         | 
| 12 | 
            +
              const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
         | 
| 13 | 
            +
             | 
| 14 | 
            +
              const RouteStub = createRoutesStub([
         | 
| 15 | 
            +
                {
         | 
| 16 | 
            +
                  Component: () => (
         | 
| 17 | 
            +
                    <SettingsForm
         | 
| 18 | 
            +
                      settings={DEFAULT_SETTINGS}
         | 
| 19 | 
            +
                      models={[DEFAULT_SETTINGS.LLM_MODEL]}
         | 
| 20 | 
            +
                      onClose={onCloseMock}
         | 
| 21 | 
            +
                    />
         | 
| 22 | 
            +
                  ),
         | 
| 23 | 
            +
                  path: "/",
         | 
| 24 | 
            +
                },
         | 
| 25 | 
            +
              ]);
         | 
| 26 | 
            +
             | 
| 27 | 
            +
              it("should save the user settings and close the modal when the form is submitted", async () => {
         | 
| 28 | 
            +
                const user = userEvent.setup();
         | 
| 29 | 
            +
                renderWithProviders(<RouteStub />);
         | 
| 30 | 
            +
             | 
| 31 | 
            +
                const saveButton = screen.getByRole("button", { name: /save/i });
         | 
| 32 | 
            +
                await user.click(saveButton);
         | 
| 33 | 
            +
             | 
| 34 | 
            +
                expect(saveSettingsSpy).toHaveBeenCalledWith(
         | 
| 35 | 
            +
                  expect.objectContaining({
         | 
| 36 | 
            +
                    llm_model: DEFAULT_SETTINGS.LLM_MODEL,
         | 
| 37 | 
            +
                  }),
         | 
| 38 | 
            +
                );
         | 
| 39 | 
            +
              });
         | 
| 40 | 
            +
            });
         | 
    	
        frontend/__tests__/components/suggestion-item.test.tsx
    ADDED
    
    | @@ -0,0 +1,58 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { render, screen } from "@testing-library/react";
         | 
| 2 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 3 | 
            +
            import { afterEach, describe, expect, it, vi } from "vitest";
         | 
| 4 | 
            +
            import { SuggestionItem } from "#/components/features/suggestions/suggestion-item";
         | 
| 5 | 
            +
            import { I18nKey } from "#/i18n/declaration";
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            vi.mock("react-i18next", () => ({
         | 
| 8 | 
            +
              useTranslation: () => ({
         | 
| 9 | 
            +
                t: (key: string) => {
         | 
| 10 | 
            +
                  const translations: Record<string, string> = {
         | 
| 11 | 
            +
                    SUGGESTIONS$TODO_APP: "ToDoリストアプリを開発する",
         | 
| 12 | 
            +
                    LANDING$BUILD_APP_BUTTON: "プルリクエストを表示するアプリを開発する",
         | 
| 13 | 
            +
                    SUGGESTIONS$HACKER_NEWS:
         | 
| 14 | 
            +
                      "Hacker Newsのトップ記事を表示するbashスクリプトを作成する",
         | 
| 15 | 
            +
                  };
         | 
| 16 | 
            +
                  return translations[key] || key;
         | 
| 17 | 
            +
                },
         | 
| 18 | 
            +
              }),
         | 
| 19 | 
            +
            }));
         | 
| 20 | 
            +
             | 
| 21 | 
            +
            describe("SuggestionItem", () => {
         | 
| 22 | 
            +
              const suggestionItem = { label: "suggestion1", value: "a long text value" };
         | 
| 23 | 
            +
              const onClick = vi.fn();
         | 
| 24 | 
            +
             | 
| 25 | 
            +
              afterEach(() => {
         | 
| 26 | 
            +
                vi.clearAllMocks();
         | 
| 27 | 
            +
              });
         | 
| 28 | 
            +
             | 
| 29 | 
            +
              it("should render a suggestion", () => {
         | 
| 30 | 
            +
                render(<SuggestionItem suggestion={suggestionItem} onClick={onClick} />);
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                expect(screen.getByTestId("suggestion")).toBeInTheDocument();
         | 
| 33 | 
            +
                expect(screen.getByText(/suggestion1/i)).toBeInTheDocument();
         | 
| 34 | 
            +
              });
         | 
| 35 | 
            +
             | 
| 36 | 
            +
              it("should render a translated suggestion when using I18nKey", async () => {
         | 
| 37 | 
            +
                const translatedSuggestion = {
         | 
| 38 | 
            +
                  label: I18nKey.SUGGESTIONS$TODO_APP,
         | 
| 39 | 
            +
                  value: "todo app value",
         | 
| 40 | 
            +
                };
         | 
| 41 | 
            +
             | 
| 42 | 
            +
                render(
         | 
| 43 | 
            +
                  <SuggestionItem suggestion={translatedSuggestion} onClick={onClick} />,
         | 
| 44 | 
            +
                );
         | 
| 45 | 
            +
             | 
| 46 | 
            +
                expect(screen.getByText("ToDoリストアプリを開発する")).toBeInTheDocument();
         | 
| 47 | 
            +
              });
         | 
| 48 | 
            +
             | 
| 49 | 
            +
              it("should call onClick when clicking a suggestion", async () => {
         | 
| 50 | 
            +
                const user = userEvent.setup();
         | 
| 51 | 
            +
                render(<SuggestionItem suggestion={suggestionItem} onClick={onClick} />);
         | 
| 52 | 
            +
             | 
| 53 | 
            +
                const suggestion = screen.getByTestId("suggestion");
         | 
| 54 | 
            +
                await user.click(suggestion);
         | 
| 55 | 
            +
             | 
| 56 | 
            +
                expect(onClick).toHaveBeenCalledWith("a long text value");
         | 
| 57 | 
            +
              });
         | 
| 58 | 
            +
            });
         | 
    	
        frontend/__tests__/components/suggestions.test.tsx
    ADDED
    
    | @@ -0,0 +1,60 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { render, screen } from "@testing-library/react";
         | 
| 2 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 3 | 
            +
            import { afterEach, describe, expect, it, vi } from "vitest";
         | 
| 4 | 
            +
            import { Suggestions } from "#/components/features/suggestions/suggestions";
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            describe("Suggestions", () => {
         | 
| 7 | 
            +
              const firstSuggestion = {
         | 
| 8 | 
            +
                label: "first-suggestion",
         | 
| 9 | 
            +
                value: "value-of-first-suggestion",
         | 
| 10 | 
            +
              };
         | 
| 11 | 
            +
              const secondSuggestion = {
         | 
| 12 | 
            +
                label: "second-suggestion",
         | 
| 13 | 
            +
                value: "value-of-second-suggestion",
         | 
| 14 | 
            +
              };
         | 
| 15 | 
            +
              const suggestions = [firstSuggestion, secondSuggestion];
         | 
| 16 | 
            +
             | 
| 17 | 
            +
              const onSuggestionClickMock = vi.fn();
         | 
| 18 | 
            +
             | 
| 19 | 
            +
              afterEach(() => {
         | 
| 20 | 
            +
                vi.clearAllMocks();
         | 
| 21 | 
            +
              });
         | 
| 22 | 
            +
             | 
| 23 | 
            +
              it("should render suggestions", () => {
         | 
| 24 | 
            +
                render(
         | 
| 25 | 
            +
                  <Suggestions
         | 
| 26 | 
            +
                    suggestions={suggestions}
         | 
| 27 | 
            +
                    onSuggestionClick={onSuggestionClickMock}
         | 
| 28 | 
            +
                  />,
         | 
| 29 | 
            +
                );
         | 
| 30 | 
            +
             | 
| 31 | 
            +
                expect(screen.getByTestId("suggestions")).toBeInTheDocument();
         | 
| 32 | 
            +
                const suggestionElements = screen.getAllByTestId("suggestion");
         | 
| 33 | 
            +
             | 
| 34 | 
            +
                expect(suggestionElements).toHaveLength(2);
         | 
| 35 | 
            +
                expect(suggestionElements[0]).toHaveTextContent("first-suggestion");
         | 
| 36 | 
            +
                expect(suggestionElements[1]).toHaveTextContent("second-suggestion");
         | 
| 37 | 
            +
              });
         | 
| 38 | 
            +
             | 
| 39 | 
            +
              it("should call onSuggestionClick when clicking a suggestion", async () => {
         | 
| 40 | 
            +
                const user = userEvent.setup();
         | 
| 41 | 
            +
                render(
         | 
| 42 | 
            +
                  <Suggestions
         | 
| 43 | 
            +
                    suggestions={suggestions}
         | 
| 44 | 
            +
                    onSuggestionClick={onSuggestionClickMock}
         | 
| 45 | 
            +
                  />,
         | 
| 46 | 
            +
                );
         | 
| 47 | 
            +
             | 
| 48 | 
            +
                const suggestionElements = screen.getAllByTestId("suggestion");
         | 
| 49 | 
            +
             | 
| 50 | 
            +
                await user.click(suggestionElements[0]);
         | 
| 51 | 
            +
                expect(onSuggestionClickMock).toHaveBeenCalledWith(
         | 
| 52 | 
            +
                  "value-of-first-suggestion",
         | 
| 53 | 
            +
                );
         | 
| 54 | 
            +
             | 
| 55 | 
            +
                await user.click(suggestionElements[1]);
         | 
| 56 | 
            +
                expect(onSuggestionClickMock).toHaveBeenCalledWith(
         | 
| 57 | 
            +
                  "value-of-second-suggestion",
         | 
| 58 | 
            +
                );
         | 
| 59 | 
            +
              });
         | 
| 60 | 
            +
            });
         | 
    	
        frontend/__tests__/components/terminal/terminal.test.tsx
    ADDED
    
    | @@ -0,0 +1,132 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { act, screen } from "@testing-library/react";
         | 
| 2 | 
            +
            import { renderWithProviders } from "test-utils";
         | 
| 3 | 
            +
            import { vi, describe, afterEach, it, expect } from "vitest";
         | 
| 4 | 
            +
            import { Command, appendInput, appendOutput } from "#/state/command-slice";
         | 
| 5 | 
            +
            import Terminal from "#/components/features/terminal/terminal";
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            const renderTerminal = (commands: Command[] = []) =>
         | 
| 8 | 
            +
              renderWithProviders(<Terminal />, {
         | 
| 9 | 
            +
                preloadedState: {
         | 
| 10 | 
            +
                  cmd: {
         | 
| 11 | 
            +
                    commands,
         | 
| 12 | 
            +
                  },
         | 
| 13 | 
            +
                },
         | 
| 14 | 
            +
              });
         | 
| 15 | 
            +
             | 
| 16 | 
            +
            describe.skip("Terminal", () => {
         | 
| 17 | 
            +
              global.ResizeObserver = vi.fn().mockImplementation(() => ({
         | 
| 18 | 
            +
                observe: vi.fn(),
         | 
| 19 | 
            +
                disconnect: vi.fn(),
         | 
| 20 | 
            +
              }));
         | 
| 21 | 
            +
             | 
| 22 | 
            +
              const mockTerminal = {
         | 
| 23 | 
            +
                open: vi.fn(),
         | 
| 24 | 
            +
                write: vi.fn(),
         | 
| 25 | 
            +
                writeln: vi.fn(),
         | 
| 26 | 
            +
                dispose: vi.fn(),
         | 
| 27 | 
            +
                onKey: vi.fn(),
         | 
| 28 | 
            +
                attachCustomKeyEventHandler: vi.fn(),
         | 
| 29 | 
            +
                loadAddon: vi.fn(),
         | 
| 30 | 
            +
              };
         | 
| 31 | 
            +
             | 
| 32 | 
            +
              vi.mock("@xterm/xterm", async (importOriginal) => ({
         | 
| 33 | 
            +
                ...(await importOriginal<typeof import("@xterm/xterm")>()),
         | 
| 34 | 
            +
                Terminal: vi.fn().mockImplementation(() => mockTerminal),
         | 
| 35 | 
            +
              }));
         | 
| 36 | 
            +
             | 
| 37 | 
            +
              afterEach(() => {
         | 
| 38 | 
            +
                vi.clearAllMocks();
         | 
| 39 | 
            +
              });
         | 
| 40 | 
            +
             | 
| 41 | 
            +
              it("should render a terminal", () => {
         | 
| 42 | 
            +
                renderTerminal();
         | 
| 43 | 
            +
             | 
| 44 | 
            +
                expect(screen.getByText("Terminal")).toBeInTheDocument();
         | 
| 45 | 
            +
                expect(mockTerminal.open).toHaveBeenCalledTimes(1);
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                expect(mockTerminal.write).toHaveBeenCalledWith("$ ");
         | 
| 48 | 
            +
              });
         | 
| 49 | 
            +
             | 
| 50 | 
            +
              it("should load commands to the terminal", () => {
         | 
| 51 | 
            +
                renderTerminal([
         | 
| 52 | 
            +
                  { type: "input", content: "INPUT" },
         | 
| 53 | 
            +
                  { type: "output", content: "OUTPUT" },
         | 
| 54 | 
            +
                ]);
         | 
| 55 | 
            +
             | 
| 56 | 
            +
                expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "INPUT");
         | 
| 57 | 
            +
                expect(mockTerminal.writeln).toHaveBeenNthCalledWith(2, "OUTPUT");
         | 
| 58 | 
            +
              });
         | 
| 59 | 
            +
             | 
| 60 | 
            +
              it("should write commands to the terminal", () => {
         | 
| 61 | 
            +
                const { store } = renderTerminal();
         | 
| 62 | 
            +
             | 
| 63 | 
            +
                act(() => {
         | 
| 64 | 
            +
                  store.dispatch(appendInput("echo Hello"));
         | 
| 65 | 
            +
                  store.dispatch(appendOutput("Hello"));
         | 
| 66 | 
            +
                });
         | 
| 67 | 
            +
             | 
| 68 | 
            +
                expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo Hello");
         | 
| 69 | 
            +
                expect(mockTerminal.writeln).toHaveBeenNthCalledWith(2, "Hello");
         | 
| 70 | 
            +
             | 
| 71 | 
            +
                act(() => {
         | 
| 72 | 
            +
                  store.dispatch(appendInput("echo World"));
         | 
| 73 | 
            +
                });
         | 
| 74 | 
            +
             | 
| 75 | 
            +
                expect(mockTerminal.writeln).toHaveBeenNthCalledWith(3, "echo World");
         | 
| 76 | 
            +
              });
         | 
| 77 | 
            +
             | 
| 78 | 
            +
              it("should load and write commands to the terminal", () => {
         | 
| 79 | 
            +
                const { store } = renderTerminal([
         | 
| 80 | 
            +
                  { type: "input", content: "echo Hello" },
         | 
| 81 | 
            +
                  { type: "output", content: "Hello" },
         | 
| 82 | 
            +
                ]);
         | 
| 83 | 
            +
             | 
| 84 | 
            +
                expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo Hello");
         | 
| 85 | 
            +
                expect(mockTerminal.writeln).toHaveBeenNthCalledWith(2, "Hello");
         | 
| 86 | 
            +
             | 
| 87 | 
            +
                act(() => {
         | 
| 88 | 
            +
                  store.dispatch(appendInput("echo Hello"));
         | 
| 89 | 
            +
                });
         | 
| 90 | 
            +
             | 
| 91 | 
            +
                expect(mockTerminal.writeln).toHaveBeenNthCalledWith(3, "echo Hello");
         | 
| 92 | 
            +
              });
         | 
| 93 | 
            +
             | 
| 94 | 
            +
              it("should end the line with a dollar sign after writing a command", () => {
         | 
| 95 | 
            +
                const { store } = renderTerminal();
         | 
| 96 | 
            +
             | 
| 97 | 
            +
                act(() => {
         | 
| 98 | 
            +
                  store.dispatch(appendInput("echo Hello"));
         | 
| 99 | 
            +
                });
         | 
| 100 | 
            +
             | 
| 101 | 
            +
                expect(mockTerminal.writeln).toHaveBeenCalledWith("echo Hello");
         | 
| 102 | 
            +
                expect(mockTerminal.write).toHaveBeenCalledWith("$ ");
         | 
| 103 | 
            +
              });
         | 
| 104 | 
            +
             | 
| 105 | 
            +
              it("should display a custom symbol if output contains a custom symbol", () => {
         | 
| 106 | 
            +
                renderTerminal([
         | 
| 107 | 
            +
                  { type: "input", content: "echo Hello" },
         | 
| 108 | 
            +
                  {
         | 
| 109 | 
            +
                    type: "output",
         | 
| 110 | 
            +
                    content:
         | 
| 111 | 
            +
                      "Hello\r\n\r\n[Python Interpreter: /openhands/poetry/openhands-5O4_aCHf-py3.12/bin/python]\nopenhands@659478cb008c:/workspace $ ",
         | 
| 112 | 
            +
                  },
         | 
| 113 | 
            +
                ]);
         | 
| 114 | 
            +
             | 
| 115 | 
            +
                expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo Hello");
         | 
| 116 | 
            +
                expect(mockTerminal.writeln).toHaveBeenNthCalledWith(2, "Hello");
         | 
| 117 | 
            +
                expect(mockTerminal.write).toHaveBeenCalledWith(
         | 
| 118 | 
            +
                  "\nopenhands@659478cb008c:/workspace $ ",
         | 
| 119 | 
            +
                );
         | 
| 120 | 
            +
              });
         | 
| 121 | 
            +
             | 
| 122 | 
            +
              // This test fails because it expects `disposeMock` to have been called before the component is unmounted.
         | 
| 123 | 
            +
              it.skip("should dispose the terminal on unmount", () => {
         | 
| 124 | 
            +
                const { unmount } = renderWithProviders(<Terminal />);
         | 
| 125 | 
            +
             | 
| 126 | 
            +
                expect(mockTerminal.dispose).not.toHaveBeenCalled();
         | 
| 127 | 
            +
             | 
| 128 | 
            +
                unmount();
         | 
| 129 | 
            +
             | 
| 130 | 
            +
                expect(mockTerminal.dispose).toHaveBeenCalledTimes(1);
         | 
| 131 | 
            +
              });
         | 
| 132 | 
            +
            });
         | 
    	
        frontend/__tests__/components/upload-image-input.test.tsx
    ADDED
    
    | @@ -0,0 +1,71 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { render, screen } from "@testing-library/react";
         | 
| 2 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 3 | 
            +
            import { afterEach, describe, expect, it, vi } from "vitest";
         | 
| 4 | 
            +
            import { UploadImageInput } from "#/components/features/images/upload-image-input";
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            describe("UploadImageInput", () => {
         | 
| 7 | 
            +
              const user = userEvent.setup();
         | 
| 8 | 
            +
              const onUploadMock = vi.fn();
         | 
| 9 | 
            +
             | 
| 10 | 
            +
              afterEach(() => {
         | 
| 11 | 
            +
                vi.clearAllMocks();
         | 
| 12 | 
            +
              });
         | 
| 13 | 
            +
             | 
| 14 | 
            +
              it("should render an input", () => {
         | 
| 15 | 
            +
                render(<UploadImageInput onUpload={onUploadMock} />);
         | 
| 16 | 
            +
                expect(screen.getByTestId("upload-image-input")).toBeInTheDocument();
         | 
| 17 | 
            +
              });
         | 
| 18 | 
            +
             | 
| 19 | 
            +
              it("should call onUpload when a file is selected", async () => {
         | 
| 20 | 
            +
                render(<UploadImageInput onUpload={onUploadMock} />);
         | 
| 21 | 
            +
             | 
| 22 | 
            +
                const file = new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" });
         | 
| 23 | 
            +
                const input = screen.getByTestId("upload-image-input");
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                await user.upload(input, file);
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                expect(onUploadMock).toHaveBeenNthCalledWith(1, [file]);
         | 
| 28 | 
            +
              });
         | 
| 29 | 
            +
             | 
| 30 | 
            +
              it("should call onUpload when multiple files are selected", async () => {
         | 
| 31 | 
            +
                render(<UploadImageInput onUpload={onUploadMock} />);
         | 
| 32 | 
            +
             | 
| 33 | 
            +
                const files = [
         | 
| 34 | 
            +
                  new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" }),
         | 
| 35 | 
            +
                  new File(["(⌐□_□)"], "chucknorris2.png", { type: "image/png" }),
         | 
| 36 | 
            +
                ];
         | 
| 37 | 
            +
                const input = screen.getByTestId("upload-image-input");
         | 
| 38 | 
            +
             | 
| 39 | 
            +
                await user.upload(input, files);
         | 
| 40 | 
            +
             | 
| 41 | 
            +
                expect(onUploadMock).toHaveBeenNthCalledWith(1, files);
         | 
| 42 | 
            +
              });
         | 
| 43 | 
            +
             | 
| 44 | 
            +
              it("should not upload any file that is not an image", async () => {
         | 
| 45 | 
            +
                render(<UploadImageInput onUpload={onUploadMock} />);
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                const file = new File(["(⌐□_□)"], "chucknorris.txt", {
         | 
| 48 | 
            +
                  type: "text/plain",
         | 
| 49 | 
            +
                });
         | 
| 50 | 
            +
                const input = screen.getByTestId("upload-image-input");
         | 
| 51 | 
            +
             | 
| 52 | 
            +
                await user.upload(input, file);
         | 
| 53 | 
            +
             | 
| 54 | 
            +
                expect(onUploadMock).not.toHaveBeenCalled();
         | 
| 55 | 
            +
              });
         | 
| 56 | 
            +
             | 
| 57 | 
            +
              it("should render custom labels", () => {
         | 
| 58 | 
            +
                const { rerender } = render(<UploadImageInput onUpload={onUploadMock} />);
         | 
| 59 | 
            +
                expect(screen.getByTestId("default-label")).toBeInTheDocument();
         | 
| 60 | 
            +
             | 
| 61 | 
            +
                function CustomLabel() {
         | 
| 62 | 
            +
                  return <span>Custom label</span>;
         | 
| 63 | 
            +
                }
         | 
| 64 | 
            +
                rerender(
         | 
| 65 | 
            +
                  <UploadImageInput onUpload={onUploadMock} label={<CustomLabel />} />,
         | 
| 66 | 
            +
                );
         | 
| 67 | 
            +
             | 
| 68 | 
            +
                expect(screen.getByText("Custom label")).toBeInTheDocument();
         | 
| 69 | 
            +
                expect(screen.queryByTestId("default-label")).not.toBeInTheDocument();
         | 
| 70 | 
            +
              });
         | 
| 71 | 
            +
            });
         | 
    	
        frontend/__tests__/components/user-actions.test.tsx
    ADDED
    
    | @@ -0,0 +1,71 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { render, screen } from "@testing-library/react";
         | 
| 2 | 
            +
            import { describe, expect, it, test, vi, afterEach } from "vitest";
         | 
| 3 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 4 | 
            +
            import { UserActions } from "#/components/features/sidebar/user-actions";
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            describe("UserActions", () => {
         | 
| 7 | 
            +
              const user = userEvent.setup();
         | 
| 8 | 
            +
              const onClickAccountSettingsMock = vi.fn();
         | 
| 9 | 
            +
              const onLogoutMock = vi.fn();
         | 
| 10 | 
            +
             | 
| 11 | 
            +
              afterEach(() => {
         | 
| 12 | 
            +
                onClickAccountSettingsMock.mockClear();
         | 
| 13 | 
            +
                onLogoutMock.mockClear();
         | 
| 14 | 
            +
              });
         | 
| 15 | 
            +
             | 
| 16 | 
            +
              it("should render", () => {
         | 
| 17 | 
            +
                render(<UserActions onLogout={onLogoutMock} />);
         | 
| 18 | 
            +
             | 
| 19 | 
            +
                expect(screen.getByTestId("user-actions")).toBeInTheDocument();
         | 
| 20 | 
            +
                expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
         | 
| 21 | 
            +
              });
         | 
| 22 | 
            +
             | 
| 23 | 
            +
              it("should toggle the user menu when the user avatar is clicked", async () => {
         | 
| 24 | 
            +
                render(<UserActions onLogout={onLogoutMock} />);
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                const userAvatar = screen.getByTestId("user-avatar");
         | 
| 27 | 
            +
                await user.click(userAvatar);
         | 
| 28 | 
            +
             | 
| 29 | 
            +
                expect(
         | 
| 30 | 
            +
                  screen.getByTestId("account-settings-context-menu"),
         | 
| 31 | 
            +
                ).toBeInTheDocument();
         | 
| 32 | 
            +
             | 
| 33 | 
            +
                await user.click(userAvatar);
         | 
| 34 | 
            +
             | 
| 35 | 
            +
                expect(
         | 
| 36 | 
            +
                  screen.queryByTestId("account-settings-context-menu"),
         | 
| 37 | 
            +
                ).not.toBeInTheDocument();
         | 
| 38 | 
            +
              });
         | 
| 39 | 
            +
             | 
| 40 | 
            +
              it("should call onLogout and close the menu when the logout option is clicked", async () => {
         | 
| 41 | 
            +
                render(
         | 
| 42 | 
            +
                  <UserActions
         | 
| 43 | 
            +
                    onLogout={onLogoutMock}
         | 
| 44 | 
            +
                    user={{ avatar_url: "https://example.com/avatar.png" }}
         | 
| 45 | 
            +
                  />,
         | 
| 46 | 
            +
                );
         | 
| 47 | 
            +
             | 
| 48 | 
            +
                const userAvatar = screen.getByTestId("user-avatar");
         | 
| 49 | 
            +
                await user.click(userAvatar);
         | 
| 50 | 
            +
             | 
| 51 | 
            +
                const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
         | 
| 52 | 
            +
                await user.click(logoutOption);
         | 
| 53 | 
            +
             | 
| 54 | 
            +
                expect(onLogoutMock).toHaveBeenCalledOnce();
         | 
| 55 | 
            +
                expect(
         | 
| 56 | 
            +
                  screen.queryByTestId("account-settings-context-menu"),
         | 
| 57 | 
            +
                ).not.toBeInTheDocument();
         | 
| 58 | 
            +
              });
         | 
| 59 | 
            +
             | 
| 60 | 
            +
              test("logout button is always enabled", async () => {
         | 
| 61 | 
            +
                render(<UserActions onLogout={onLogoutMock} />);
         | 
| 62 | 
            +
             | 
| 63 | 
            +
                const userAvatar = screen.getByTestId("user-avatar");
         | 
| 64 | 
            +
                await user.click(userAvatar);
         | 
| 65 | 
            +
             | 
| 66 | 
            +
                const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
         | 
| 67 | 
            +
                await user.click(logoutOption);
         | 
| 68 | 
            +
             | 
| 69 | 
            +
                expect(onLogoutMock).toHaveBeenCalledOnce();
         | 
| 70 | 
            +
              });
         | 
| 71 | 
            +
            });
         | 
    	
        frontend/__tests__/components/user-avatar.test.tsx
    ADDED
    
    | @@ -0,0 +1,68 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { render, screen } from "@testing-library/react";
         | 
| 2 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 3 | 
            +
            import { afterEach, describe, expect, it, vi } from "vitest";
         | 
| 4 | 
            +
            import { UserAvatar } from "#/components/features/sidebar/user-avatar";
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            describe("UserAvatar", () => {
         | 
| 7 | 
            +
              const onClickMock = vi.fn();
         | 
| 8 | 
            +
             | 
| 9 | 
            +
              afterEach(() => {
         | 
| 10 | 
            +
                onClickMock.mockClear();
         | 
| 11 | 
            +
              });
         | 
| 12 | 
            +
             | 
| 13 | 
            +
              it("(default) should render the placeholder avatar when the user is logged out", () => {
         | 
| 14 | 
            +
                render(<UserAvatar onClick={onClickMock} />);
         | 
| 15 | 
            +
                expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
         | 
| 16 | 
            +
                expect(
         | 
| 17 | 
            +
                  screen.getByLabelText("USER$AVATAR_PLACEHOLDER"),
         | 
| 18 | 
            +
                ).toBeInTheDocument();
         | 
| 19 | 
            +
              });
         | 
| 20 | 
            +
             | 
| 21 | 
            +
              it("should call onClick when clicked", async () => {
         | 
| 22 | 
            +
                const user = userEvent.setup();
         | 
| 23 | 
            +
                render(<UserAvatar onClick={onClickMock} />);
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                const userAvatarContainer = screen.getByTestId("user-avatar");
         | 
| 26 | 
            +
                await user.click(userAvatarContainer);
         | 
| 27 | 
            +
             | 
| 28 | 
            +
                expect(onClickMock).toHaveBeenCalledOnce();
         | 
| 29 | 
            +
              });
         | 
| 30 | 
            +
             | 
| 31 | 
            +
              it("should display the user's avatar when available", () => {
         | 
| 32 | 
            +
                render(
         | 
| 33 | 
            +
                  <UserAvatar
         | 
| 34 | 
            +
                    onClick={onClickMock}
         | 
| 35 | 
            +
                    avatarUrl="https://example.com/avatar.png"
         | 
| 36 | 
            +
                  />,
         | 
| 37 | 
            +
                );
         | 
| 38 | 
            +
             | 
| 39 | 
            +
                expect(screen.getByAltText("AVATAR$ALT_TEXT")).toBeInTheDocument();
         | 
| 40 | 
            +
                expect(
         | 
| 41 | 
            +
                  screen.queryByLabelText("USER$AVATAR_PLACEHOLDER"),
         | 
| 42 | 
            +
                ).not.toBeInTheDocument();
         | 
| 43 | 
            +
              });
         | 
| 44 | 
            +
             | 
| 45 | 
            +
              it("should display a loading spinner instead of an avatar when isLoading is true", () => {
         | 
| 46 | 
            +
                const { rerender } = render(<UserAvatar onClick={onClickMock} />);
         | 
| 47 | 
            +
                expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
         | 
| 48 | 
            +
                expect(
         | 
| 49 | 
            +
                  screen.getByLabelText("USER$AVATAR_PLACEHOLDER"),
         | 
| 50 | 
            +
                ).toBeInTheDocument();
         | 
| 51 | 
            +
             | 
| 52 | 
            +
                rerender(<UserAvatar onClick={onClickMock} isLoading />);
         | 
| 53 | 
            +
                expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
         | 
| 54 | 
            +
                expect(
         | 
| 55 | 
            +
                  screen.queryByLabelText("USER$AVATAR_PLACEHOLDER"),
         | 
| 56 | 
            +
                ).not.toBeInTheDocument();
         | 
| 57 | 
            +
             | 
| 58 | 
            +
                rerender(
         | 
| 59 | 
            +
                  <UserAvatar
         | 
| 60 | 
            +
                    onClick={onClickMock}
         | 
| 61 | 
            +
                    avatarUrl="https://example.com/avatar.png"
         | 
| 62 | 
            +
                    isLoading
         | 
| 63 | 
            +
                  />,
         | 
| 64 | 
            +
                );
         | 
| 65 | 
            +
                expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
         | 
| 66 | 
            +
                expect(screen.queryByAltText("AVATAR$ALT_TEXT")).not.toBeInTheDocument();
         | 
| 67 | 
            +
              });
         | 
| 68 | 
            +
            });
         | 
    	
        frontend/__tests__/context/ws-client-provider.test.tsx
    ADDED
    
    | @@ -0,0 +1,98 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { describe, it, expect, vi, beforeEach } from "vitest";
         | 
| 2 | 
            +
            import { render, waitFor } from "@testing-library/react";
         | 
| 3 | 
            +
            import React from "react";
         | 
| 4 | 
            +
            import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
         | 
| 5 | 
            +
            import {
         | 
| 6 | 
            +
              updateStatusWhenErrorMessagePresent,
         | 
| 7 | 
            +
              WsClientProvider,
         | 
| 8 | 
            +
              useWsClient,
         | 
| 9 | 
            +
            } from "#/context/ws-client-provider";
         | 
| 10 | 
            +
             | 
| 11 | 
            +
            describe("Propagate error message", () => {
         | 
| 12 | 
            +
              it("should do nothing when no message was passed from server", () => {
         | 
| 13 | 
            +
                updateStatusWhenErrorMessagePresent(null);
         | 
| 14 | 
            +
                updateStatusWhenErrorMessagePresent(undefined);
         | 
| 15 | 
            +
                updateStatusWhenErrorMessagePresent({});
         | 
| 16 | 
            +
                updateStatusWhenErrorMessagePresent({ message: null });
         | 
| 17 | 
            +
              });
         | 
| 18 | 
            +
             | 
| 19 | 
            +
              it.todo("should display error to user when present");
         | 
| 20 | 
            +
             | 
| 21 | 
            +
              it.todo("should display error including translation id when present");
         | 
| 22 | 
            +
            });
         | 
| 23 | 
            +
             | 
| 24 | 
            +
            // Create a mock for socket.io-client
         | 
| 25 | 
            +
            const mockEmit = vi.fn();
         | 
| 26 | 
            +
            const mockOn = vi.fn();
         | 
| 27 | 
            +
            const mockOff = vi.fn();
         | 
| 28 | 
            +
            const mockDisconnect = vi.fn();
         | 
| 29 | 
            +
             | 
| 30 | 
            +
            vi.mock("socket.io-client", () => ({
         | 
| 31 | 
            +
              io: vi.fn(() => ({
         | 
| 32 | 
            +
                emit: mockEmit,
         | 
| 33 | 
            +
                on: mockOn,
         | 
| 34 | 
            +
                off: mockOff,
         | 
| 35 | 
            +
                disconnect: mockDisconnect,
         | 
| 36 | 
            +
                io: {
         | 
| 37 | 
            +
                  opts: {
         | 
| 38 | 
            +
                    query: {},
         | 
| 39 | 
            +
                  },
         | 
| 40 | 
            +
                },
         | 
| 41 | 
            +
              })),
         | 
| 42 | 
            +
            }));
         | 
| 43 | 
            +
             | 
| 44 | 
            +
            // Mock component to test the hook
         | 
| 45 | 
            +
            function TestComponent() {
         | 
| 46 | 
            +
              const { send } = useWsClient();
         | 
| 47 | 
            +
             | 
| 48 | 
            +
              React.useEffect(() => {
         | 
| 49 | 
            +
                // Send a test event
         | 
| 50 | 
            +
                send({ type: "test_event" });
         | 
| 51 | 
            +
              }, [send]);
         | 
| 52 | 
            +
             | 
| 53 | 
            +
              return <div>Test Component</div>;
         | 
| 54 | 
            +
            }
         | 
| 55 | 
            +
             | 
| 56 | 
            +
            describe("WsClientProvider", () => {
         | 
| 57 | 
            +
              beforeEach(() => {
         | 
| 58 | 
            +
                vi.clearAllMocks();
         | 
| 59 | 
            +
                vi.mock("#/hooks/query/use-active-conversation", () => ({
         | 
| 60 | 
            +
                  useActiveConversation: () => {
         | 
| 61 | 
            +
                    return { data: {
         | 
| 62 | 
            +
                    conversation_id: "1",
         | 
| 63 | 
            +
                    title: "Conversation 1",
         | 
| 64 | 
            +
                    selected_repository: null,
         | 
| 65 | 
            +
                    last_updated_at: "2021-10-01T12:00:00Z",
         | 
| 66 | 
            +
                    created_at: "2021-10-01T12:00:00Z",
         | 
| 67 | 
            +
                    status: "RUNNING" as const,
         | 
| 68 | 
            +
                    url: null,
         | 
| 69 | 
            +
                    session_api_key: null,
         | 
| 70 | 
            +
                  }}},
         | 
| 71 | 
            +
                }));
         | 
| 72 | 
            +
              });
         | 
| 73 | 
            +
             | 
| 74 | 
            +
              it("should emit oh_user_action event when send is called", async () => {
         | 
| 75 | 
            +
                const { getByText } = render(<TestComponent />, {
         | 
| 76 | 
            +
                  wrapper: ({ children }) => (
         | 
| 77 | 
            +
                    <QueryClientProvider client={new QueryClient()}>
         | 
| 78 | 
            +
                      <WsClientProvider conversationId="test-conversation-id">
         | 
| 79 | 
            +
                        {children}
         | 
| 80 | 
            +
                      </WsClientProvider>
         | 
| 81 | 
            +
                    </QueryClientProvider>
         | 
| 82 | 
            +
                  ),
         | 
| 83 | 
            +
                });
         | 
| 84 | 
            +
             | 
| 85 | 
            +
                // Assert
         | 
| 86 | 
            +
                expect(getByText("Test Component")).toBeInTheDocument();
         | 
| 87 | 
            +
             | 
| 88 | 
            +
                // Wait for the emit call to happen (useEffect needs time to run)
         | 
| 89 | 
            +
                await waitFor(
         | 
| 90 | 
            +
                  () => {
         | 
| 91 | 
            +
                    expect(mockEmit).toHaveBeenCalledWith("oh_user_action", {
         | 
| 92 | 
            +
                      type: "test_event",
         | 
| 93 | 
            +
                    });
         | 
| 94 | 
            +
                  },
         | 
| 95 | 
            +
                  { timeout: 1000 },
         | 
| 96 | 
            +
                );
         | 
| 97 | 
            +
              });
         | 
| 98 | 
            +
            });
         | 
    	
        frontend/__tests__/hooks/mutation/use-save-settings.test.tsx
    ADDED
    
    | @@ -0,0 +1,36 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { renderHook, waitFor } from "@testing-library/react";
         | 
| 2 | 
            +
            import { describe, expect, it, vi } from "vitest";
         | 
| 3 | 
            +
            import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
         | 
| 4 | 
            +
            import OpenHands from "#/api/open-hands";
         | 
| 5 | 
            +
            import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            describe("useSaveSettings", () => {
         | 
| 8 | 
            +
              it("should send an empty string for llm_api_key if an empty string is passed, otherwise undefined", async () => {
         | 
| 9 | 
            +
                const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
         | 
| 10 | 
            +
                const { result } = renderHook(() => useSaveSettings(), {
         | 
| 11 | 
            +
                  wrapper: ({ children }) => (
         | 
| 12 | 
            +
                    <QueryClientProvider client={new QueryClient()}>
         | 
| 13 | 
            +
                      {children}
         | 
| 14 | 
            +
                    </QueryClientProvider>
         | 
| 15 | 
            +
                  ),
         | 
| 16 | 
            +
                });
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                result.current.mutate({ llm_api_key: "" });
         | 
| 19 | 
            +
                await waitFor(() => {
         | 
| 20 | 
            +
                  expect(saveSettingsSpy).toHaveBeenCalledWith(
         | 
| 21 | 
            +
                    expect.objectContaining({
         | 
| 22 | 
            +
                      llm_api_key: "",
         | 
| 23 | 
            +
                    }),
         | 
| 24 | 
            +
                  );
         | 
| 25 | 
            +
                });
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                result.current.mutate({ llm_api_key: null });
         | 
| 28 | 
            +
                await waitFor(() => {
         | 
| 29 | 
            +
                  expect(saveSettingsSpy).toHaveBeenCalledWith(
         | 
| 30 | 
            +
                    expect.objectContaining({
         | 
| 31 | 
            +
                      llm_api_key: undefined,
         | 
| 32 | 
            +
                    }),
         | 
| 33 | 
            +
                  );
         | 
| 34 | 
            +
                });
         | 
| 35 | 
            +
              });
         | 
| 36 | 
            +
            });
         | 
    	
        frontend/__tests__/hooks/use-click-outside-element.test.tsx
    ADDED
    
    | @@ -0,0 +1,36 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { render, screen } from "@testing-library/react";
         | 
| 2 | 
            +
            import userEvent from "@testing-library/user-event";
         | 
| 3 | 
            +
            import { expect, test, vi } from "vitest";
         | 
| 4 | 
            +
            import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            interface ClickOutsideTestComponentProps {
         | 
| 7 | 
            +
              callback: () => void;
         | 
| 8 | 
            +
            }
         | 
| 9 | 
            +
             | 
| 10 | 
            +
            function ClickOutsideTestComponent({
         | 
| 11 | 
            +
              callback,
         | 
| 12 | 
            +
            }: ClickOutsideTestComponentProps) {
         | 
| 13 | 
            +
              const ref = useClickOutsideElement<HTMLDivElement>(callback);
         | 
| 14 | 
            +
             | 
| 15 | 
            +
              return (
         | 
| 16 | 
            +
                <div>
         | 
| 17 | 
            +
                  <div data-testid="inside-element" ref={ref} />
         | 
| 18 | 
            +
                  <div data-testid="outside-element" />
         | 
| 19 | 
            +
                </div>
         | 
| 20 | 
            +
              );
         | 
| 21 | 
            +
            }
         | 
| 22 | 
            +
             | 
| 23 | 
            +
            test("call the callback when the element is clicked outside", async () => {
         | 
| 24 | 
            +
              const user = userEvent.setup();
         | 
| 25 | 
            +
              const callback = vi.fn();
         | 
| 26 | 
            +
              render(<ClickOutsideTestComponent callback={callback} />);
         | 
| 27 | 
            +
             | 
| 28 | 
            +
              const insideElement = screen.getByTestId("inside-element");
         | 
| 29 | 
            +
              const outsideElement = screen.getByTestId("outside-element");
         | 
| 30 | 
            +
             | 
| 31 | 
            +
              await user.click(insideElement);
         | 
| 32 | 
            +
              expect(callback).not.toHaveBeenCalled();
         | 
| 33 | 
            +
             | 
| 34 | 
            +
              await user.click(outsideElement);
         | 
| 35 | 
            +
              expect(callback).toHaveBeenCalled();
         | 
| 36 | 
            +
            });
         | 
    	
        frontend/__tests__/hooks/use-rate.test.ts
    ADDED
    
    | @@ -0,0 +1,93 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { act, renderHook } from "@testing-library/react";
         | 
| 2 | 
            +
            import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
         | 
| 3 | 
            +
            import { useRate } from "#/hooks/use-rate";
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            describe("useRate", () => {
         | 
| 6 | 
            +
              beforeEach(() => {
         | 
| 7 | 
            +
                vi.useFakeTimers();
         | 
| 8 | 
            +
              });
         | 
| 9 | 
            +
             | 
| 10 | 
            +
              afterEach(() => {
         | 
| 11 | 
            +
                vi.useRealTimers();
         | 
| 12 | 
            +
              });
         | 
| 13 | 
            +
             | 
| 14 | 
            +
              it("should initialize", () => {
         | 
| 15 | 
            +
                const { result } = renderHook(() => useRate());
         | 
| 16 | 
            +
             | 
| 17 | 
            +
                expect(result.current.items).toHaveLength(0);
         | 
| 18 | 
            +
                expect(result.current.rate).toBeNull();
         | 
| 19 | 
            +
                expect(result.current.lastUpdated).toBeNull();
         | 
| 20 | 
            +
                expect(result.current.isUnderThreshold).toBe(true);
         | 
| 21 | 
            +
              });
         | 
| 22 | 
            +
             | 
| 23 | 
            +
              it("should handle the case of a single element", () => {
         | 
| 24 | 
            +
                const { result } = renderHook(() => useRate());
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                act(() => {
         | 
| 27 | 
            +
                  result.current.record(123);
         | 
| 28 | 
            +
                });
         | 
| 29 | 
            +
             | 
| 30 | 
            +
                expect(result.current.items).toHaveLength(1);
         | 
| 31 | 
            +
                expect(result.current.lastUpdated).not.toBeNull();
         | 
| 32 | 
            +
              });
         | 
| 33 | 
            +
             | 
| 34 | 
            +
              it("should return the difference between the last two elements", () => {
         | 
| 35 | 
            +
                const { result } = renderHook(() => useRate());
         | 
| 36 | 
            +
             | 
| 37 | 
            +
                vi.setSystemTime(500);
         | 
| 38 | 
            +
                act(() => {
         | 
| 39 | 
            +
                  result.current.record(4);
         | 
| 40 | 
            +
                });
         | 
| 41 | 
            +
             | 
| 42 | 
            +
                vi.advanceTimersByTime(500);
         | 
| 43 | 
            +
                act(() => {
         | 
| 44 | 
            +
                  result.current.record(9);
         | 
| 45 | 
            +
                });
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                expect(result.current.items).toHaveLength(2);
         | 
| 48 | 
            +
                expect(result.current.rate).toBe(5);
         | 
| 49 | 
            +
                expect(result.current.lastUpdated).toBe(1000);
         | 
| 50 | 
            +
              });
         | 
| 51 | 
            +
             | 
| 52 | 
            +
              it("should update isUnderThreshold after [threshold]ms of no activity", () => {
         | 
| 53 | 
            +
                const { result } = renderHook(() => useRate({ threshold: 500 }));
         | 
| 54 | 
            +
             | 
| 55 | 
            +
                expect(result.current.isUnderThreshold).toBe(true);
         | 
| 56 | 
            +
             | 
| 57 | 
            +
                act(() => {
         | 
| 58 | 
            +
                  // not sure if fake timers is buggy with intervals,
         | 
| 59 | 
            +
                  // but I need to call it twice to register
         | 
| 60 | 
            +
                  vi.advanceTimersToNextTimer();
         | 
| 61 | 
            +
                  vi.advanceTimersToNextTimer();
         | 
| 62 | 
            +
                });
         | 
| 63 | 
            +
             | 
| 64 | 
            +
                expect(result.current.isUnderThreshold).toBe(false);
         | 
| 65 | 
            +
              });
         | 
| 66 | 
            +
             | 
| 67 | 
            +
              it("should return an isUnderThreshold boolean", () => {
         | 
| 68 | 
            +
                const { result } = renderHook(() => useRate({ threshold: 500 }));
         | 
| 69 | 
            +
             | 
| 70 | 
            +
                vi.setSystemTime(500);
         | 
| 71 | 
            +
                act(() => {
         | 
| 72 | 
            +
                  result.current.record(400);
         | 
| 73 | 
            +
                });
         | 
| 74 | 
            +
                act(() => {
         | 
| 75 | 
            +
                  result.current.record(1000);
         | 
| 76 | 
            +
                });
         | 
| 77 | 
            +
             | 
| 78 | 
            +
                expect(result.current.isUnderThreshold).toBe(false);
         | 
| 79 | 
            +
             | 
| 80 | 
            +
                act(() => {
         | 
| 81 | 
            +
                  result.current.record(1500);
         | 
| 82 | 
            +
                });
         | 
| 83 | 
            +
             | 
| 84 | 
            +
                expect(result.current.isUnderThreshold).toBe(true);
         | 
| 85 | 
            +
             | 
| 86 | 
            +
                act(() => {
         | 
| 87 | 
            +
                  vi.advanceTimersToNextTimer();
         | 
| 88 | 
            +
                  vi.advanceTimersToNextTimer();
         | 
| 89 | 
            +
                });
         | 
| 90 | 
            +
             | 
| 91 | 
            +
                expect(result.current.isUnderThreshold).toBe(false);
         | 
| 92 | 
            +
              });
         | 
| 93 | 
            +
            });
         | 
    	
        frontend/__tests__/hooks/use-terminal.test.tsx
    ADDED
    
    | @@ -0,0 +1,111 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import { beforeAll, describe, expect, it, vi } from "vitest";
         | 
| 2 | 
            +
            import { afterEach } from "node:test";
         | 
| 3 | 
            +
            import { useTerminal } from "#/hooks/use-terminal";
         | 
| 4 | 
            +
            import { Command } from "#/state/command-slice";
         | 
| 5 | 
            +
            import { AgentState } from "#/types/agent-state";
         | 
| 6 | 
            +
            import { renderWithProviders } from "../../test-utils";
         | 
| 7 | 
            +
             | 
| 8 | 
            +
            // Mock the WsClient context
         | 
| 9 | 
            +
            vi.mock("#/context/ws-client-provider", () => ({
         | 
| 10 | 
            +
              useWsClient: () => ({
         | 
| 11 | 
            +
                send: vi.fn(),
         | 
| 12 | 
            +
                status: "CONNECTED",
         | 
| 13 | 
            +
                isLoadingMessages: false,
         | 
| 14 | 
            +
                events: [],
         | 
| 15 | 
            +
              }),
         | 
| 16 | 
            +
            }));
         | 
| 17 | 
            +
             | 
| 18 | 
            +
            interface TestTerminalComponentProps {
         | 
| 19 | 
            +
              commands: Command[];
         | 
| 20 | 
            +
            }
         | 
| 21 | 
            +
             | 
| 22 | 
            +
            function TestTerminalComponent({
         | 
| 23 | 
            +
              commands,
         | 
| 24 | 
            +
            }: TestTerminalComponentProps) {
         | 
| 25 | 
            +
              const ref = useTerminal({ commands });
         | 
| 26 | 
            +
              return <div ref={ref} />;
         | 
| 27 | 
            +
            }
         | 
| 28 | 
            +
             | 
| 29 | 
            +
            describe("useTerminal", () => {
         | 
| 30 | 
            +
              const mockTerminal = vi.hoisted(() => ({
         | 
| 31 | 
            +
                loadAddon: vi.fn(),
         | 
| 32 | 
            +
                open: vi.fn(),
         | 
| 33 | 
            +
                write: vi.fn(),
         | 
| 34 | 
            +
                writeln: vi.fn(),
         | 
| 35 | 
            +
                onKey: vi.fn(),
         | 
| 36 | 
            +
                attachCustomKeyEventHandler: vi.fn(),
         | 
| 37 | 
            +
                dispose: vi.fn(),
         | 
| 38 | 
            +
              }));
         | 
| 39 | 
            +
             | 
| 40 | 
            +
              beforeAll(() => {
         | 
| 41 | 
            +
                // mock ResizeObserver
         | 
| 42 | 
            +
                window.ResizeObserver = vi.fn().mockImplementation(() => ({
         | 
| 43 | 
            +
                  observe: vi.fn(),
         | 
| 44 | 
            +
                  unobserve: vi.fn(),
         | 
| 45 | 
            +
                  disconnect: vi.fn(),
         | 
| 46 | 
            +
                }));
         | 
| 47 | 
            +
             | 
| 48 | 
            +
                // mock Terminal
         | 
| 49 | 
            +
                vi.mock("@xterm/xterm", async (importOriginal) => ({
         | 
| 50 | 
            +
                  ...(await importOriginal<typeof import("@xterm/xterm")>()),
         | 
| 51 | 
            +
                  Terminal: vi.fn().mockImplementation(() => mockTerminal),
         | 
| 52 | 
            +
                }));
         | 
| 53 | 
            +
              });
         | 
| 54 | 
            +
             | 
| 55 | 
            +
              afterEach(() => {
         | 
| 56 | 
            +
                vi.clearAllMocks();
         | 
| 57 | 
            +
              });
         | 
| 58 | 
            +
             | 
| 59 | 
            +
              it("should render", () => {
         | 
| 60 | 
            +
                renderWithProviders(<TestTerminalComponent commands={[]} />, {
         | 
| 61 | 
            +
                  preloadedState: {
         | 
| 62 | 
            +
                    agent: { curAgentState: AgentState.RUNNING },
         | 
| 63 | 
            +
                    cmd: { commands: [] },
         | 
| 64 | 
            +
                  },
         | 
| 65 | 
            +
                });
         | 
| 66 | 
            +
              });
         | 
| 67 | 
            +
             | 
| 68 | 
            +
              it("should render the commands in the terminal", () => {
         | 
| 69 | 
            +
                const commands: Command[] = [
         | 
| 70 | 
            +
                  { content: "echo hello", type: "input" },
         | 
| 71 | 
            +
                  { content: "hello", type: "output" },
         | 
| 72 | 
            +
                ];
         | 
| 73 | 
            +
             | 
| 74 | 
            +
                renderWithProviders(<TestTerminalComponent commands={commands} />, {
         | 
| 75 | 
            +
                  preloadedState: {
         | 
| 76 | 
            +
                    agent: { curAgentState: AgentState.RUNNING },
         | 
| 77 | 
            +
                    cmd: { commands },
         | 
| 78 | 
            +
                  },
         | 
| 79 | 
            +
                });
         | 
| 80 | 
            +
             | 
| 81 | 
            +
                expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo hello");
         | 
| 82 | 
            +
                expect(mockTerminal.writeln).toHaveBeenNthCalledWith(2, "hello");
         | 
| 83 | 
            +
              });
         | 
| 84 | 
            +
             | 
| 85 | 
            +
              // This test is no longer relevant as secrets filtering has been removed
         | 
| 86 | 
            +
              it.skip("should hide secrets in the terminal", () => {
         | 
| 87 | 
            +
                const secret = "super_secret_github_token";
         | 
| 88 | 
            +
                const anotherSecret = "super_secret_another_token";
         | 
| 89 | 
            +
                const commands: Command[] = [
         | 
| 90 | 
            +
                  {
         | 
| 91 | 
            +
                    content: `export GITHUB_TOKEN=${secret},${anotherSecret},${secret}`,
         | 
| 92 | 
            +
                    type: "input",
         | 
| 93 | 
            +
                  },
         | 
| 94 | 
            +
                  { content: secret, type: "output" },
         | 
| 95 | 
            +
                ];
         | 
| 96 | 
            +
             | 
| 97 | 
            +
                renderWithProviders(
         | 
| 98 | 
            +
                  <TestTerminalComponent
         | 
| 99 | 
            +
                    commands={commands}
         | 
| 100 | 
            +
                  />,
         | 
| 101 | 
            +
                  {
         | 
| 102 | 
            +
                    preloadedState: {
         | 
| 103 | 
            +
                      agent: { curAgentState: AgentState.RUNNING },
         | 
| 104 | 
            +
                      cmd: { commands },
         | 
| 105 | 
            +
                    },
         | 
| 106 | 
            +
                  },
         | 
| 107 | 
            +
                );
         | 
| 108 | 
            +
             | 
| 109 | 
            +
                // This test is no longer relevant as secrets filtering has been removed
         | 
| 110 | 
            +
              });
         | 
| 111 | 
            +
            });
         |