Backup-bdg commited on
Commit
b59aa07
·
verified ·
1 Parent(s): 680c11c

Upload 565 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. frontend/README.md +254 -0
  3. frontend/__tests__/api/file-service/file-service.api.test.ts +29 -0
  4. frontend/__tests__/components/browser.test.tsx +86 -0
  5. frontend/__tests__/components/buttons/copy-to-clipboard.test.tsx +40 -0
  6. frontend/__tests__/components/chat-message.test.tsx +72 -0
  7. frontend/__tests__/components/chat/action-suggestions.test.tsx +132 -0
  8. frontend/__tests__/components/chat/chat-input.test.tsx +256 -0
  9. frontend/__tests__/components/chat/chat-interface.test.tsx +366 -0
  10. frontend/__tests__/components/chat/expandable-message.test.tsx +141 -0
  11. frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx +74 -0
  12. frontend/__tests__/components/context-menu/context-menu-list-item.test.tsx +44 -0
  13. frontend/__tests__/components/features/analytics/analytics-consent-form-modal.test.tsx +30 -0
  14. frontend/__tests__/components/features/auth-modal.test.tsx +47 -0
  15. frontend/__tests__/components/features/chat/path-component.test.tsx +34 -0
  16. frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx +489 -0
  17. frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx +290 -0
  18. frontend/__tests__/components/features/conversation-panel/utils.ts +17 -0
  19. frontend/__tests__/components/features/home/home-header.test.tsx +93 -0
  20. frontend/__tests__/components/features/home/repo-connector.test.tsx +241 -0
  21. frontend/__tests__/components/features/home/repo-selection-form.test.tsx +259 -0
  22. frontend/__tests__/components/features/home/task-card.test.tsx +108 -0
  23. frontend/__tests__/components/features/home/task-suggestions.test.tsx +96 -0
  24. frontend/__tests__/components/features/payment/payment-form.test.tsx +180 -0
  25. frontend/__tests__/components/features/settings/api-keys-manager.test.tsx +59 -0
  26. frontend/__tests__/components/features/sidebar/sidebar.test.tsx +32 -0
  27. frontend/__tests__/components/feedback-actions.test.tsx +76 -0
  28. frontend/__tests__/components/feedback-form.test.tsx +68 -0
  29. frontend/__tests__/components/file-operations.test.tsx +11 -0
  30. frontend/__tests__/components/image-preview.test.tsx +37 -0
  31. frontend/__tests__/components/interactive-chat-box.test.tsx +190 -0
  32. frontend/__tests__/components/jupyter/jupyter.test.tsx +45 -0
  33. frontend/__tests__/components/landing-translations.test.tsx +190 -0
  34. frontend/__tests__/components/modals/base-modal/base-modal.test.tsx +151 -0
  35. frontend/__tests__/components/modals/settings/model-selector.test.tsx +136 -0
  36. frontend/__tests__/components/settings/settings-input.test.tsx +109 -0
  37. frontend/__tests__/components/settings/settings-switch.test.tsx +64 -0
  38. frontend/__tests__/components/shared/brand-button.test.tsx +55 -0
  39. frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx +40 -0
  40. frontend/__tests__/components/suggestion-item.test.tsx +58 -0
  41. frontend/__tests__/components/suggestions.test.tsx +60 -0
  42. frontend/__tests__/components/terminal/terminal.test.tsx +132 -0
  43. frontend/__tests__/components/upload-image-input.test.tsx +71 -0
  44. frontend/__tests__/components/user-actions.test.tsx +71 -0
  45. frontend/__tests__/components/user-avatar.test.tsx +68 -0
  46. frontend/__tests__/context/ws-client-provider.test.tsx +98 -0
  47. frontend/__tests__/hooks/mutation/use-save-settings.test.tsx +36 -0
  48. frontend/__tests__/hooks/use-click-outside-element.test.tsx +36 -0
  49. frontend/__tests__/hooks/use-rate.test.ts +93 -0
  50. 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
+ });