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: "[email protected]",
|
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 |
+
});
|