Compare commits

..

2 Commits

Author SHA1 Message Date
enyst
5236c3094a Fix variable scope issue in get_authenticated_git_url method
- Move provider and repo_name variable initialization outside try block
- Initialize with default values before attempting repository verification
- Ensures variables are always available regardless of exception path
- Add comprehensive unit tests to verify the fix works correctly

Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-07 07:05:55 +00:00
enyst
2d8c0168ae Fix offline functionality by handling network errors in repository verification
- Add exception handling in manage_conversations.py to catch network errors during repository verification
- Allow conversation creation to proceed when offline while preserving authentication error validation
- Add similar handling in provider.py get_authenticated_git_url method with fallback to public URLs
- Add provider inference logic to determine git provider when verification fails
- Add comprehensive tests for offline conversation creation scenarios

Fixes #8950

Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-07 06:41:58 +00:00
558 changed files with 8293 additions and 18240 deletions

View File

@@ -1,29 +0,0 @@
# Feature branch preview for enterprise code
name: Enterprise Preview
# Run on PRs labeled
on:
pull_request:
types: [labeled]
# Match ghcr-build.yml, but don't interrupt it.
concurrency:
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
cancel-in-progress: false
jobs:
# This must happen for the PR Docker workflow when the label is present,
# and also if it's added after the fact. Thus, it exists in both places.
enterprise-preview:
name: Enterprise preview
if: github.event.label.name == 'deploy'
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
# This should match the version in ghcr-build.yml
- name: Trigger remote job
run: |
curl --fail-with-body -sS -X POST \
-H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
-H "Accept: application/vnd.github+json" \
-d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \
https://api.github.com/repos/All-Hands-AI/deploy/actions/workflows/deploy.yaml/dispatches

View File

@@ -176,10 +176,8 @@ jobs:
# Do not build enterprise in forks
if: github.event.pull_request.head.repo.fork != true
steps:
- name: Checkout
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
# Set up Docker Buildx for better performance
- name: Set up Docker Buildx
@@ -237,11 +235,12 @@ jobs:
enterprise-preview:
name: Enterprise preview
if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'deploy')
if: |
(github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name == 'deploy') ||
(github.event_name == 'pull_request' && github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'deploy'))
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [ghcr_build_enterprise]
steps:
# This should match the version in enterprise-preview.yml
- name: Trigger remote job
run: |
curl --fail-with-body -sS -X POST \

View File

@@ -1,70 +0,0 @@
# Workflow that checks MDX format in docs/ folder
name: MDX Lint
# Run on pushes to main and on pull requests that modify docs/ files
on:
push:
branches:
- main
paths:
- 'docs/**/*.mdx'
pull_request:
paths:
- 'docs/**/*.mdx'
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
concurrency:
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
cancel-in-progress: true
jobs:
mdx-lint:
name: Lint MDX files
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v4
- name: Install Node.js 22
uses: useblacksmith/setup-node@v5
with:
node-version: 22
- name: Install MDX dependencies
run: |
npm install @mdx-js/mdx@3 glob@10
- name: Validate MDX files
run: |
node -e "
const {compile} = require('@mdx-js/mdx');
const fs = require('fs');
const path = require('path');
const glob = require('glob');
async function validateMDXFiles() {
const files = glob.sync('docs/**/*.mdx');
console.log('Found', files.length, 'MDX files to validate');
let hasErrors = false;
for (const file of files) {
try {
const content = fs.readFileSync(file, 'utf8');
await compile(content);
console.log('✅ MDX parsing successful for', file);
} catch (err) {
console.error('❌ MDX parsing failed for', file, ':', err.message);
hasErrors = true;
}
}
if (hasErrors) {
console.error('\\n❌ Some MDX files have parsing errors. Please fix them before merging.');
process.exit(1);
} else {
console.log('\\n✅ All MDX files are valid!');
}
}
validateMDXFiles();
"

View File

@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.56-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.55-nikolaik`
## Develop inside Docker container

View File

@@ -79,17 +79,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
You can also run OpenHands directly with Docker:
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.56
docker.all-hands.dev/all-hands-ai/openhands:0.55
```
</details>

View File

@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.56
docker.all-hands.dev/all-hands-ai/openhands:0.55
```
> **注意**: 如果您在0.44版本之前使用过OpenHands您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。

View File

@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.56
docker.all-hands.dev/all-hands-ai/openhands:0.55
```
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。

View File

@@ -219,14 +219,6 @@ correct_num = 5
api_key = ""
model = "gpt-4o"
# Example routing LLM configuration for multimodal model routing
# Uncomment and configure to enable model routing with a secondary model
#[llm.secondary_model]
#model = "kimi-k2"
#api_key = ""
#for_routing = true
#max_input_tokens = 128000
#################################### Agent ###################################
# Configuration for agents (group name starts with 'agent')
@@ -488,14 +480,3 @@ type = "noop"
# Run the runtime sandbox container in privileged mode for use with docker-in-docker
#privileged = false
#################################### Model Routing ############################
# Configuration for experimental model routing feature
# Enables intelligent switching between different LLM models for specific purposes
##############################################################################
[model_routing]
# Router to use for model selection
# Available options:
# - "noop_router" (default): No routing, always uses primary LLM
# - "multimodal_router": A router that switches between primary and secondary models, depending on whether the input is multimodal or not
#router_name = "noop_router"

View File

@@ -12,7 +12,7 @@ services:
- SANDBOX_API_HOSTNAME=host.docker.internal
- DOCKER_HOST_ADDR=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.56-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.55-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -124,7 +124,7 @@ This tagging approach allows OpenHands to efficiently manage both development an
OpenHands supports both bind mounts and Docker named volumes in SandboxConfig.volumes:
- Bind mount: "/abs/host/path:/container/path[:mode]"
- Named volume: "volume:`<name>`:/container/path[:mode]" or any non-absolute host spec treated as a named volume
- Named volume: "volume:<name>:/container/path[:mode]" or any non-absolute host spec treated as a named volume
Overlay mode (copy-on-write layer) is supported for bind mounts by appending ":overlay" to the mode (e.g., ":ro,overlay").
To enable overlay COW, set SANDBOX_VOLUME_OVERLAYS to a writable host directory; per-container upper/work dirs are created under it. If SANDBOX_VOLUME_OVERLAYS is unset, overlay mounts are skipped.

View File

@@ -8,11 +8,6 @@ description: This page outlines all available configuration options for OpenHand
In GUI Mode, any settings applied through the Settings UI will take precedence.
</Note>
<Note>
**Looking for Environment Variables?** All configuration options can also be set using environment variables.
See the [Environment Variables Reference](./environment-variables) for a complete list with examples.
</Note>
## Location of the `config.toml` File
When running OpenHands in CLI, headless, or development mode, you can use a project-specific `config.toml` file for configuration, which must be
@@ -23,11 +18,6 @@ specify a different path to the `config.toml` file.
The core configuration options are defined in the `[core]` section of the `config.toml` file.
Core configuration options can be set as environment variables by converting to uppercase. For example:
- `debug` → `DEBUG`
- `cache_dir` → `CACHE_DIR`
- `runtime` → `RUNTIME`
### Workspace
- `workspace_base` **(Deprecated)**
- Type: `str`
@@ -151,11 +141,6 @@ The LLM (Large Language Model) configuration options are defined in the `[llm]`
To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LLM_NUM_RETRIES`.
All LLM configuration options can be set as environment variables by prefixing with `LLM_` and converting to uppercase. For example:
- `model` → `LLM_MODEL`
- `api_key` → `LLM_API_KEY`
- `base_url` → `LLM_BASE_URL`
<Note>
For development setups, you can also define custom named LLM configurations. See [Custom LLM Configurations](./llms/custom-llm-configs) for details.
</Note>
@@ -292,11 +277,6 @@ For development setups, you can also define custom named LLM configurations. See
The agent configuration options are defined in the `[agent]` and `[agent.<agent_name>]` sections of the `config.toml` file.
Agent configuration options can be set as environment variables by prefixing with `AGENT_` and converting to uppercase. For example:
- `enable_browsing` → `AGENT_ENABLE_BROWSING`
- `function_calling` → `AGENT_FUNCTION_CALLING`
- `llm_config` → `AGENT_LLM_CONFIG`
### LLM Configuration
- `llm_config`
- Type: `str`
@@ -348,11 +328,6 @@ The sandbox configuration options are defined in the `[sandbox]` section of the
To use these with the docker command, pass in `-e SANDBOX_<option>`. Example: `-e SANDBOX_TIMEOUT`.
All sandbox configuration options can be set as environment variables by prefixing with `SANDBOX_` and converting to uppercase. For example:
- `timeout` → `SANDBOX_TIMEOUT`
- `user_id` → `SANDBOX_USER_ID`
- `base_container_image` → `SANDBOX_BASE_CONTAINER_IMAGE`
### Execution
- `timeout`
- Type: `int`
@@ -415,10 +390,6 @@ The security configuration options are defined in the `[security]` section of th
To use these with the docker command, pass in `-e SECURITY_<option>`. Example: `-e SECURITY_CONFIRMATION_MODE`.
All security configuration options can be set as environment variables by prefixing with `SECURITY_` and converting to uppercase. For example:
- `confirmation_mode` → `SECURITY_CONFIRMATION_MODE`
- `security_analyzer` → `SECURITY_SECURITY_ANALYZER`
### Confirmation Mode
- `confirmation_mode`
- Type: `bool`

View File

@@ -1,251 +0,0 @@
---
title: Environment Variables Reference
description: Complete reference of all environment variables supported by OpenHands
---
This page provides a reference of environment variables that can be used to configure OpenHands. Environment variables provide an alternative to TOML configuration files and are particularly useful for containerized deployments, CI/CD pipelines, and cloud environments.
## Environment Variable Naming Convention
OpenHands follows a consistent naming pattern for environment variables:
- **Core settings**: Direct uppercase mapping (e.g., `debug` → `DEBUG`)
- **LLM settings**: Prefixed with `LLM_` (e.g., `model` → `LLM_MODEL`)
- **Agent settings**: Prefixed with `AGENT_` (e.g., `enable_browsing` → `AGENT_ENABLE_BROWSING`)
- **Sandbox settings**: Prefixed with `SANDBOX_` (e.g., `timeout` → `SANDBOX_TIMEOUT`)
- **Security settings**: Prefixed with `SECURITY_` (e.g., `confirmation_mode` → `SECURITY_CONFIRMATION_MODE`)
## Core Configuration Variables
These variables correspond to the `[core]` section in `config.toml`:
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `DEBUG` | boolean | `false` | Enable debug logging throughout the application |
| `DISABLE_COLOR` | boolean | `false` | Disable colored output in terminal |
| `CACHE_DIR` | string | `"/tmp/cache"` | Directory path for caching |
| `SAVE_TRAJECTORY_PATH` | string | `"./trajectories"` | Path to store conversation trajectories |
| `REPLAY_TRAJECTORY_PATH` | string | `""` | Path to load and replay a trajectory file |
| `FILE_STORE_PATH` | string | `"/tmp/file_store"` | File store directory path |
| `FILE_STORE` | string | `"memory"` | File store type (`memory`, `local`, etc.) |
| `FILE_UPLOADS_MAX_FILE_SIZE_MB` | integer | `0` | Maximum file upload size in MB (0 = no limit) |
| `FILE_UPLOADS_RESTRICT_FILE_TYPES` | boolean | `false` | Whether to restrict file upload types |
| `FILE_UPLOADS_ALLOWED_EXTENSIONS` | list | `[".*"]` | List of allowed file extensions for uploads |
| `MAX_BUDGET_PER_TASK` | float | `0.0` | Maximum budget per task (0.0 = no limit) |
| `MAX_ITERATIONS` | integer | `100` | Maximum number of iterations per task |
| `RUNTIME` | string | `"docker"` | Runtime environment (`docker`, `local`, `cli`, etc.) |
| `DEFAULT_AGENT` | string | `"CodeActAgent"` | Default agent class to use |
| `JWT_SECRET` | string | auto-generated | JWT secret for authentication |
| `RUN_AS_OPENHANDS` | boolean | `true` | Whether to run as the openhands user |
| `VOLUMES` | string | `""` | Volume mounts in format `host:container[:mode]` |
## LLM Configuration Variables
These variables correspond to the `[llm]` section in `config.toml`:
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `LLM_MODEL` | string | `"claude-3-5-sonnet-20241022"` | LLM model to use |
| `LLM_API_KEY` | string | `""` | API key for the LLM provider |
| `LLM_BASE_URL` | string | `""` | Custom API base URL |
| `LLM_API_VERSION` | string | `""` | API version to use |
| `LLM_TEMPERATURE` | float | `0.0` | Sampling temperature |
| `LLM_TOP_P` | float | `1.0` | Top-p sampling parameter |
| `LLM_MAX_INPUT_TOKENS` | integer | `0` | Maximum input tokens (0 = no limit) |
| `LLM_MAX_OUTPUT_TOKENS` | integer | `0` | Maximum output tokens (0 = no limit) |
| `LLM_MAX_MESSAGE_CHARS` | integer | `30000` | Maximum characters that will be sent to the model in observation content |
| `LLM_TIMEOUT` | integer | `0` | API timeout in seconds (0 = no timeout) |
| `LLM_NUM_RETRIES` | integer | `8` | Number of retry attempts |
| `LLM_RETRY_MIN_WAIT` | integer | `15` | Minimum wait time between retries (seconds) |
| `LLM_RETRY_MAX_WAIT` | integer | `120` | Maximum wait time between retries (seconds) |
| `LLM_RETRY_MULTIPLIER` | float | `2.0` | Exponential backoff multiplier |
| `LLM_DROP_PARAMS` | boolean | `false` | Drop unsupported parameters without error |
| `LLM_CACHING_PROMPT` | boolean | `true` | Enable prompt caching if supported |
| `LLM_DISABLE_VISION` | boolean | `false` | Disable vision capabilities for cost reduction |
| `LLM_CUSTOM_LLM_PROVIDER` | string | `""` | Custom LLM provider name |
| `LLM_OLLAMA_BASE_URL` | string | `""` | Base URL for Ollama API |
| `LLM_INPUT_COST_PER_TOKEN` | float | `0.0` | Cost per input token |
| `LLM_OUTPUT_COST_PER_TOKEN` | float | `0.0` | Cost per output token |
| `LLM_REASONING_EFFORT` | string | `""` | Reasoning effort for o-series models (`low`, `medium`, `high`) |
### AWS Configuration
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `LLM_AWS_ACCESS_KEY_ID` | string | `""` | AWS access key ID |
| `LLM_AWS_SECRET_ACCESS_KEY` | string | `""` | AWS secret access key |
| `LLM_AWS_REGION_NAME` | string | `""` | AWS region name |
## Agent Configuration Variables
These variables correspond to the `[agent]` section in `config.toml`:
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `AGENT_LLM_CONFIG` | string | `""` | Name of LLM config group to use |
| `AGENT_FUNCTION_CALLING` | boolean | `true` | Enable function calling |
| `AGENT_ENABLE_BROWSING` | boolean | `false` | Enable browsing delegate |
| `AGENT_ENABLE_LLM_EDITOR` | boolean | `false` | Enable LLM-based editor |
| `AGENT_ENABLE_JUPYTER` | boolean | `false` | Enable Jupyter integration |
| `AGENT_ENABLE_HISTORY_TRUNCATION` | boolean | `true` | Enable history truncation |
| `AGENT_ENABLE_PROMPT_EXTENSIONS` | boolean | `true` | Enable microagents (prompt extensions) |
| `AGENT_DISABLED_MICROAGENTS` | list | `[]` | List of microagents to disable |
## Sandbox Configuration Variables
These variables correspond to the `[sandbox]` section in `config.toml`:
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `SANDBOX_TIMEOUT` | integer | `120` | Sandbox timeout in seconds |
| `SANDBOX_USER_ID` | integer | `1000` | User ID for sandbox processes |
| `SANDBOX_BASE_CONTAINER_IMAGE` | string | `"nikolaik/python-nodejs:python3.12-nodejs22"` | Base container image |
| `SANDBOX_USE_HOST_NETWORK` | boolean | `false` | Use host networking |
| `SANDBOX_RUNTIME_BINDING_ADDRESS` | string | `"0.0.0.0"` | Runtime binding address |
| `SANDBOX_ENABLE_AUTO_LINT` | boolean | `false` | Enable automatic linting |
| `SANDBOX_INITIALIZE_PLUGINS` | boolean | `true` | Initialize sandbox plugins |
| `SANDBOX_RUNTIME_EXTRA_DEPS` | string | `""` | Extra dependencies to install |
| `SANDBOX_RUNTIME_STARTUP_ENV_VARS` | dict | `{}` | Environment variables for runtime |
| `SANDBOX_BROWSERGYM_EVAL_ENV` | string | `""` | BrowserGym evaluation environment |
| `SANDBOX_VOLUMES` | string | `""` | Volume mounts (replaces deprecated workspace settings) |
| `SANDBOX_RUNTIME_CONTAINER_IMAGE` | string | `""` | Pre-built runtime container image |
| `SANDBOX_KEEP_RUNTIME_ALIVE` | boolean | `false` | Keep runtime alive after session ends |
| `SANDBOX_PAUSE_CLOSED_RUNTIMES` | boolean | `false` | Pause instead of stopping closed runtimes |
| `SANDBOX_CLOSE_DELAY` | integer | `300` | Delay before closing idle runtimes (seconds) |
| `SANDBOX_RM_ALL_CONTAINERS` | boolean | `false` | Remove all containers when stopping |
| `SANDBOX_ENABLE_GPU` | boolean | `false` | Enable GPU support |
| `SANDBOX_CUDA_VISIBLE_DEVICES` | string | `""` | Specify GPU devices by ID |
| `SANDBOX_VSCODE_PORT` | integer | auto | Specific port for VSCode server |
### Sandbox Environment Variables
Variables prefixed with `SANDBOX_ENV_` are passed through to the sandbox environment:
| Environment Variable | Description |
|---------------------|-------------|
| `SANDBOX_ENV_*` | Any variable with this prefix is passed to the sandbox (e.g., `SANDBOX_ENV_OPENAI_API_KEY`) |
## Security Configuration Variables
These variables correspond to the `[security]` section in `config.toml`:
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `SECURITY_CONFIRMATION_MODE` | boolean | `false` | Enable confirmation mode for actions |
| `SECURITY_SECURITY_ANALYZER` | string | `"llm"` | Security analyzer to use (`llm`, `invariant`) |
| `SECURITY_ENABLE_SECURITY_ANALYZER` | boolean | `true` | Enable security analysis |
## Debug and Logging Variables
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `DEBUG` | boolean | `false` | Enable general debug logging |
| `DEBUG_LLM` | boolean | `false` | Enable LLM-specific debug logging |
| `DEBUG_RUNTIME` | boolean | `false` | Enable runtime debug logging |
| `LOG_TO_FILE` | boolean | auto | Log to file (auto-enabled when DEBUG=true) |
## Runtime-Specific Variables
### Docker Runtime
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `SANDBOX_VOLUME_OVERLAYS` | string | `""` | Volume overlay configurations |
### Remote Runtime
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `SANDBOX_API_KEY` | string | `""` | API key for remote runtime |
| `SANDBOX_REMOTE_RUNTIME_API_URL` | string | `""` | Remote runtime API URL |
### Local Runtime
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `RUNTIME_URL` | string | `""` | Runtime URL for local runtime |
| `RUNTIME_URL_PATTERN` | string | `""` | Runtime URL pattern |
| `RUNTIME_ID` | string | `""` | Runtime identifier |
| `LOCAL_RUNTIME_MODE` | string | `""` | Enable local runtime mode (`1` to enable) |
## Integration Variables
### GitHub Integration
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `GITHUB_TOKEN` | string | `""` | GitHub personal access token |
### Third-Party API Keys
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `OPENAI_API_KEY` | string | `""` | OpenAI API key |
| `ANTHROPIC_API_KEY` | string | `""` | Anthropic API key |
| `GOOGLE_API_KEY` | string | `""` | Google API key |
| `AZURE_API_KEY` | string | `""` | Azure API key |
| `TAVILY_API_KEY` | string | `""` | Tavily search API key |
## Server Configuration Variables
These are primarily used when running OpenHands as a server:
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `FRONTEND_PORT` | integer | `3000` | Frontend server port |
| `BACKEND_PORT` | integer | `8000` | Backend server port |
| `FRONTEND_HOST` | string | `"localhost"` | Frontend host address |
| `BACKEND_HOST` | string | `"localhost"` | Backend host address |
| `WEB_HOST` | string | `"localhost"` | Web server host |
| `SERVE_FRONTEND` | boolean | `true` | Whether to serve frontend |
## Deprecated Variables
These variables are deprecated and should be replaced:
| Environment Variable | Replacement | Description |
|---------------------|-------------|-------------|
| `WORKSPACE_BASE` | `SANDBOX_VOLUMES` | Use volume mounting instead |
| `WORKSPACE_MOUNT_PATH` | `SANDBOX_VOLUMES` | Use volume mounting instead |
| `WORKSPACE_MOUNT_PATH_IN_SANDBOX` | `SANDBOX_VOLUMES` | Use volume mounting instead |
| `WORKSPACE_MOUNT_REWRITE` | `SANDBOX_VOLUMES` | Use volume mounting instead |
## Usage Examples
### Basic Setup with OpenAI
```bash
export LLM_MODEL="gpt-4o"
export LLM_API_KEY="your-openai-api-key"
export DEBUG=true
```
### Docker Deployment with Custom Volumes
```bash
export RUNTIME="docker"
export SANDBOX_VOLUMES="/host/workspace:/workspace:rw,/host/data:/data:ro"
export SANDBOX_TIMEOUT=300
```
### Remote Runtime Configuration
```bash
export RUNTIME="remote"
export SANDBOX_API_KEY="your-remote-api-key"
export SANDBOX_REMOTE_RUNTIME_API_URL="https://your-runtime-api.com"
```
### Security-Enhanced Setup
```bash
export SECURITY_CONFIRMATION_MODE=true
export SECURITY_SECURITY_ANALYZER="llm"
export DEBUG_RUNTIME=true
```
## Notes
1. **Boolean Values**: Environment variables expecting boolean values accept `true`/`false`, `1`/`0`, or `yes`/`no` (case-insensitive).
2. **List Values**: Lists should be provided as Python literal strings, e.g., `AGENT_DISABLED_MICROAGENTS='["microagent1", "microagent2"]'`.
3. **Dictionary Values**: Dictionaries should be provided as Python literal strings, e.g., `SANDBOX_RUNTIME_STARTUP_ENV_VARS='{"KEY": "value"}'`.
4. **Precedence**: Environment variables take precedence over TOML configuration files.
5. **Docker Usage**: When using Docker, pass environment variables with the `-e` flag:
```bash
docker run -e LLM_API_KEY="your-key" -e DEBUG=true openhands/openhands
```
6. **Validation**: Invalid environment variable values will be logged as errors and fall back to defaults.

View File

@@ -113,7 +113,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -122,7 +122,7 @@ docker run -it \
-v ~/.openhands:/.openhands \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.56 \
docker.all-hands.dev/all-hands-ai/openhands:0.55 \
python -m openhands.cli.entry --override-cli-mode true
```

View File

@@ -52,7 +52,7 @@ Set environment variables and run the Docker command:
```bash
# Set required environment variables
export SANDBOX_VOLUMES="/path/to/workspace:/workspace:rw" # Format: host_path:container_path:mode
export SANDBOX_VOLUMES="/path/to/workspace" # See SANDBOX_VOLUMES docs for details
export LLM_MODEL="anthropic/claude-sonnet-4-20250514"
export LLM_API_KEY="your-api-key"
export SANDBOX_SELECTED_REPO="owner/repo-name" # Optional: requires GITHUB_TOKEN
@@ -61,7 +61,7 @@ export GITHUB_TOKEN="your-token" # Required for repository operations
# Run OpenHands
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -73,7 +73,7 @@ docker run -it \
-v ~/.openhands:/.openhands \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.56 \
docker.all-hands.dev/all-hands-ai/openhands:0.55 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.56
docker.all-hands.dev/all-hands-ai/openhands:0.55
```
2. Wait until the server is running (see log below):
```
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.56
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.55
Starting OpenHands...
Running OpenHands as root
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None

View File

@@ -116,17 +116,17 @@ Note that you'll still need `uv` installed for the default MCP servers to work p
<Accordion title="Docker Command (Click to expand)">
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.56
docker.all-hands.dev/all-hands-ai/openhands:0.55
```
</Accordion>

View File

@@ -46,8 +46,7 @@ repos:
- types-toml
- types-redis
- lxml
# OpenHands package in repo root
- ./
# TODO: Add OpenHands in parent
- stripe==11.5.0
- pygithub==2.6.1
# To see gaps add `--html-report mypy-report/`

View File

@@ -7,11 +7,15 @@ warn_unreachable = True
warn_redundant_casts = True
no_implicit_optional = True
strict_optional = True
disable_error_code = type-abstract
exclude = (^enterprise/migrations/.*)
exclude = (^enterprise/migrations/.*|^openhands/.*)
[mypy-enterprise.tests.unit.test_auth_routes.*]
disable_error_code = union-attr
[mypy-enterprise.sync.install_gitlab_webhooks.*]
disable_error_code = redundant-cast
# Let the other config check base openhands packages
[mypy-openhands.*]
follow_imports = skip
ignore_missing_imports = True

View File

@@ -55,7 +55,7 @@ class SaaSExperimentManager(ExperimentManager):
@staticmethod
def run_config_variant_test(
user_id: str | None, conversation_id: str, config: OpenHandsConfig
user_id: str, conversation_id: str, config: OpenHandsConfig
) -> OpenHandsConfig:
"""
Run agent config variant test and potentially modify the OpenHands config

View File

@@ -390,24 +390,24 @@ class GitHubDataCollector:
merged_by = None
merge_commit_sha = None
if is_merged:
merged_by = (pr_data.get('mergedBy') or {}).get('login')
merge_commit_sha = (pr_data.get('mergeCommit') or {}).get('oid')
merged_by = pr_data.get('mergedBy', {}).get('login')
merge_commit_sha = pr_data.get('mergeCommit', {}).get('oid')
return {
'repo_metadata': self._extract_repo_metadata(repo_data),
'pr_metadata': {
'username': (pr_data.get('author') or {}).get('login'),
'number': pr_data.get('number'),
'title': pr_data.get('title'),
'body': pr_data.get('body'),
'username': pr_data.get('author', {}).get('login'),
'number': pr_data['number'],
'title': pr_data['title'],
'body': pr_data['body'],
'comments': pr_comments,
},
'commits': commits,
'review_comments': review_comments,
'merge_status': {
'merged': pr_data.get('merged'),
'merged': pr_data['merged'],
'merged_by': merged_by,
'state': pr_data.get('state'),
'state': pr_data['state'],
'merge_commit_sha': merge_commit_sha,
},
'openhands_stats': {

View File

@@ -62,13 +62,7 @@ class GitlabManager(Manager):
logger.warning(f'Got invalid keyloak user id for GitLab User {user_id}')
return False
# Importing here prevents circular import
from integrations.gitlab.gitlab_service import SaaSGitLabService
gitlab_service: SaaSGitLabService = GitLabServiceImpl(
external_auth_id=keycloak_user_id
)
gitlab_service = GitLabServiceImpl(external_auth_id=keycloak_user_id)
return await gitlab_service.user_has_write_access(project_id)
async def receive_message(self, message: Message):
@@ -125,13 +119,7 @@ class GitlabManager(Manager):
gitlab_view: The GitLab view object containing issue/PR/comment info
"""
keycloak_user_id = gitlab_view.user_info.keycloak_user_id
# Importing here prevents circular import
from integrations.gitlab.gitlab_service import SaaSGitLabService
gitlab_service: SaaSGitLabService = GitLabServiceImpl(
external_auth_id=keycloak_user_id
)
gitlab_service = GitLabServiceImpl(external_auth_id=keycloak_user_id)
outgoing_message = message.message

View File

@@ -47,14 +47,14 @@ class GitlabIssue(ResolverViewInterface):
)
self.previous_comments = await gitlab_service.get_issue_or_mr_comments(
str(self.project_id), self.issue_number, is_mr=self.is_mr
self.project_id, self.issue_number, is_mr=self.is_mr
)
(
self.title,
self.description,
) = await gitlab_service.get_issue_or_mr_title_and_body(
str(self.project_id), self.issue_number, is_mr=self.is_mr
self.project_id, self.issue_number, is_mr=self.is_mr
)
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
@@ -199,11 +199,11 @@ class GitlabInlineMRComment(GitlabMRComment):
self.title,
self.description,
) = await gitlab_service.get_issue_or_mr_title_and_body(
str(self.project_id), self.issue_number, is_mr=self.is_mr
self.project_id, self.issue_number, is_mr=self.is_mr
)
self.previous_comments = await gitlab_service.get_review_thread_comments(
str(self.project_id), self.issue_number, self.discussion_id
self.project_id, self.issue_number, self.discussion_id
)
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:

View File

@@ -172,17 +172,6 @@ def get_summary_for_agent_state(
return f'OpenHands encountered an error: **{reason}**.\n\n[See the conversation]({conversation_link}) for more information.'
if state == AgentState.AWAITING_USER_INPUT:
logger.info(
'Agent is awaiting user input',
extra={
'agent_state': state.value,
'conversation_link': conversation_link,
'observation_reason': getattr(observation, 'reason', None),
},
)
return f'OpenHands is waiting for your input. [Continue the conversation]({conversation_link}) to provide additional instructions.'
# Log unknown agent state as error
logger.error(
'Unknown error: Unhandled agent state',

View File

@@ -1,50 +0,0 @@
"""add cancellation fields to subscription_access
Revision ID: 075
Revises: 074
Create Date: 2025-01-11
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '075'
down_revision: Union[str, None] = '074'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add cancelled_at field to track cancellation timestamp
op.add_column(
'subscription_access',
sa.Column('cancelled_at', sa.DateTime(timezone=True), nullable=True),
)
# Add stripe_subscription_id field to enable cancellation via Stripe API
op.add_column(
'subscription_access',
sa.Column('stripe_subscription_id', sa.String(), nullable=True),
)
# Create index on stripe_subscription_id for efficient lookups
op.create_index(
'ix_subscription_access_stripe_subscription_id',
'subscription_access',
['stripe_subscription_id'],
)
def downgrade() -> None:
# Drop index
op.drop_index(
'ix_subscription_access_stripe_subscription_id', 'subscription_access'
)
# Drop columns
op.drop_column('subscription_access', 'stripe_subscription_id')
op.drop_column('subscription_access', 'cancelled_at')

177
enterprise/poetry.lock generated
View File

@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
[[package]]
name = "aiofiles"
@@ -1426,73 +1426,73 @@ yaml = ["pyyaml (>=6.0.1)"]
[[package]]
name = "ddtrace"
version = "3.13.0"
version = "3.12.4"
description = "Datadog APM client library"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "ddtrace-3.13.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:12122a8e7089ab40cad2cd6bb51834859aa0a27babf3256a73630e6ee2315455"},
{file = "ddtrace-3.13.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:02fab2c444b87f290850b3d750e17ccdf49ace3baf8ff3305e8147f6fdf0dc50"},
{file = "ddtrace-3.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a003ffa4649dab4971d3557ce2d85eb2c5d335ebc7152196cbf780171fd4b5e1"},
{file = "ddtrace-3.13.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:52b2458b6f0f4725156d46c6cb5410f98568a61cc890bb270515c9caad3a522d"},
{file = "ddtrace-3.13.0-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:9160222e476e18af95ef687bd548f8e86b3815896bf7cd1d42a9b43005e058e2"},
{file = "ddtrace-3.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:464e245c2114c722ad4240b73b1c598f83cc1c7bdc9001aec3083f914c1cacc0"},
{file = "ddtrace-3.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:21901a58e938dbeba0ca6c49b8ba1480d07eea5b057845ae4ff3a706d833137f"},
{file = "ddtrace-3.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:40e00faced483a3eac0b499cf191a38fbf8bb060a3872029ee3299871f87bdd9"},
{file = "ddtrace-3.13.0-cp310-cp310-win32.whl", hash = "sha256:d15593cb804d74094df1a71167a70136b7616579259ce2b26279f2762354e709"},
{file = "ddtrace-3.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:5de44e7c595d25745665fa1cc44c0f0b4c7ad79be06d0de74f6e0edb2c8ec351"},
{file = "ddtrace-3.13.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:68c38ac75cc3668e9284873f5e84c3e104880d68c3891ed13614e0614c46f5b0"},
{file = "ddtrace-3.13.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:6d8811c4b7397384aff7e54b7399647f4c1c0e9167792cb45adb2d3553fc20a2"},
{file = "ddtrace-3.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:029b6e6c50984b1976c6b0970e60184919dab9514441d08683a50a5d52a05326"},
{file = "ddtrace-3.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8de2a060400ee89422ecfd3269dfd2e113f4f9dae00f6fcd3ed9e53e2223a26a"},
{file = "ddtrace-3.13.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:bb0738048ea0e49e6bec9be2bf5c68a24d7ea3b27bf956147378366aacb4ca4b"},
{file = "ddtrace-3.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:04cf4776c52cfb19914bf6e84242d110197d15426c34e45b14fa63d9085767d5"},
{file = "ddtrace-3.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c32774e90593ebb264d53d6523b71243b9ba794ae5689e38ad522afddd06c0b"},
{file = "ddtrace-3.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a01f99b0287c2bbd8b305e0cb54b382eaf2a0fe89ba82f2f68fcbdd9fed040cd"},
{file = "ddtrace-3.13.0-cp311-cp311-win32.whl", hash = "sha256:b37efa3e7b487bd60e6fb89186d98c1ad1727871074f3519c9ca92feea7e5cd0"},
{file = "ddtrace-3.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:112e4d96f02f94247528b65f046c69d360d6eca75b9e7cd2f95fde1c14e2002e"},
{file = "ddtrace-3.13.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:13ac5bc306df5719d00a8b1f6925efbb9dd0ba5e121edcc2acfef24c57b3deb5"},
{file = "ddtrace-3.13.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:b3bdfc3cabab85f91a4f24264a2d0f6f74984a5b5994c62072c6e3b5e05320f3"},
{file = "ddtrace-3.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:11b10f8dfadb4b1372aee820be6c22071138ede2ddb32f73486255d5879b283f"},
{file = "ddtrace-3.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a3d68007602797f280c971a286c3f05bdff66c12a68a3e0bd67cb5bbc1c4a67a"},
{file = "ddtrace-3.13.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:abd00a5b83d85a951dd976a59c8673bedacdc1ea9e6acb8e72545f73bddc7879"},
{file = "ddtrace-3.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5dbe392b2182e6dd617e946cf41da7e3207387b912809ebe8338b794b08750b2"},
{file = "ddtrace-3.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6b38b4ad9e3f1b3421022587748f6a687ed722eae16033392fc875b5c67d6c5a"},
{file = "ddtrace-3.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f38a1545495c8db3318621400a3d407db457e3550a397e39cf883f41919e1dc8"},
{file = "ddtrace-3.13.0-cp312-cp312-win32.whl", hash = "sha256:e01bb1b305b777001d310911bd73d1fd88c9c212258caaf65f1422a0dbef1a3b"},
{file = "ddtrace-3.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8dbb9aa23a36599754932e79df28eb07fdd3aaca515297bf58dfcdac608273da"},
{file = "ddtrace-3.13.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:397a68e476d8bd9aa14f8c097bc9014510948e76a0110842ab6f5fa1143ad153"},
{file = "ddtrace-3.13.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:fab1b06476169e2cf6a098130c44eeb3d9d8205b5a91ae8afdb7d2b4d2d0b0be"},
{file = "ddtrace-3.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:653f75c3e838366108464f9555120f61ef0589974f346ed2c2c9cb3001d3fc6a"},
{file = "ddtrace-3.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80f694c3d3984c9bd3bd7818268be7ece02071c67671c6d8c815e6888ae4e78c"},
{file = "ddtrace-3.13.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:be16f9c0583767db13403e78ac7ac7b4c103e8b7eaac6deef7c897408f24b940"},
{file = "ddtrace-3.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c5490a715fbb70ee03840c6a3146c76d7bfa27d5b679ce4c1a7b368eff7dee9f"},
{file = "ddtrace-3.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:45235a81c828e2d6bdb4ac1bbe55582c190bc27e8820eeae5c0478ea11f1ed81"},
{file = "ddtrace-3.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a9374a8cf405169a9eab7791cc94d5dc5753eefe806b5bee9909eef3d5e339d"},
{file = "ddtrace-3.13.0-cp313-cp313-win32.whl", hash = "sha256:6bc1648a1c046e6061e29d94d2003c17820cc3a7f1c24322dab654abe9bb30db"},
{file = "ddtrace-3.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8823e95f69dd3fc8a884d092fdc54a3c3078daf0f90e824fceda7e0f26acbc70"},
{file = "ddtrace-3.13.0-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:338932a8511a815d5198ec09d55f6850fcb9c679a1b50a3a28fdc0ff99bd800a"},
{file = "ddtrace-3.13.0-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:c14fe68cfc1c11b9d560a3026e3e5dcdd59b725b6ce79cda66d23a26b37751e6"},
{file = "ddtrace-3.13.0-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fd70631f5c70ccafde14df98a9f807e537222f13d6f03fa08bf1308eaf89301"},
{file = "ddtrace-3.13.0-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09c71f464afb05d7f1a2758112f4feaf2bca39daa22a6c3f75999227eb40e2ec"},
{file = "ddtrace-3.13.0-cp38-cp38-manylinux_2_28_i686.whl", hash = "sha256:481b13365e3cf100bf35f305bd0680695fa369e67a9ec4e1b41788df62ac1d0b"},
{file = "ddtrace-3.13.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:0d99ebbef96f406e0436bd21a92354c3c338fc6a8fe85d0a26fe942bc563b721"},
{file = "ddtrace-3.13.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:28086003f1c5ce3e84239eea9d624afcc386b38f2115c3438ea49beff84ff861"},
{file = "ddtrace-3.13.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f280e80560f5c953bb16b168bed1b6f7d527ef98f81860422500040ee57a7aba"},
{file = "ddtrace-3.13.0-cp38-cp38-win32.whl", hash = "sha256:82f0b76c83e368c686594f42809d727143ee89a879d1a76cde9f75d4cea07cb4"},
{file = "ddtrace-3.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:dd7b3a9933b11b2fce4dd4cb34ee465bc3c87024444a2e6a5a653f424bae8e37"},
{file = "ddtrace-3.13.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:c1ce2123615e4618050ec7fc96e296283f23c45eddcf3a2fe94386f7513795a4"},
{file = "ddtrace-3.13.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:9dae3459edd5cc7a1124596b524b743b1d2bddf4155ca9679c599740ad71546d"},
{file = "ddtrace-3.13.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d36d0cf84a39b29f88dcb06a20fc3f2c7a9eca8eb1fd5d15bc5a51de095962c"},
{file = "ddtrace-3.13.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d4a55277a3db32fee06030fd0dbf77c2e867541c3e4b65e68e46b03971401173"},
{file = "ddtrace-3.13.0-cp39-cp39-manylinux_2_28_i686.whl", hash = "sha256:cb97593d9739f0be6647e19edc6fc6998dfba3e78fb9d2df5fef9ebfb117aa85"},
{file = "ddtrace-3.13.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f905e5bb2db4c154fca25ded15c3e1d633951db2d6ed2989f630ee3afd589cc0"},
{file = "ddtrace-3.13.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:de3ecc6428330117ef063ef6a90326669a9a4cf3e766674228ec384edca52bb1"},
{file = "ddtrace-3.13.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eec340ef5152e971dc6ab075945dfa7c41285f8441bea0a78f5f4cd1f6b9aab6"},
{file = "ddtrace-3.13.0-cp39-cp39-win32.whl", hash = "sha256:8c2831f928393f934bfe9f9b5f0eeb22a0f5c88fbebe32cc5106b24409847d6b"},
{file = "ddtrace-3.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:e04f4c41e7216422e9cd101bee70a823f56dddb8333158e1e72b73332e1a311d"},
{file = "ddtrace-3.13.0.tar.gz", hash = "sha256:d7d3d82795d29cf2385aa692ee5c65e469ebfa34469941055af66eae2eefa374"},
{file = "ddtrace-3.12.4-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:222dc483f22a065795f473cad6fc6e798ecf9da9f4fc99ca87f1ba70f34d21b1"},
{file = "ddtrace-3.12.4-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:196f114a70b75320876f6861c10435c6d4ea50e0f406328b0862a021c344d002"},
{file = "ddtrace-3.12.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4200e8b057b29ce3ba0889a9d423e4d105b0ba35d4bd58ba2670763018909623"},
{file = "ddtrace-3.12.4-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7fc1449d511e04e8b2596eee6d1ad2d3420dff23f6dfd8a899c5e3e03dfe8ba5"},
{file = "ddtrace-3.12.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ebae69206957837341cd94bbe78e5242395f7571455dfe911b56ea2f7404ada"},
{file = "ddtrace-3.12.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a08cd25234358a2427494d4059ee12afc83e083bad65f2bd62417fd935caa737"},
{file = "ddtrace-3.12.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fbe90ff2c914c753116807ddffde9065ecbf9944bdc4932862c3f5835485004d"},
{file = "ddtrace-3.12.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1b3be9452bc76f730203b86272f8312c7e195b3125f964900df3f41c39ec0c94"},
{file = "ddtrace-3.12.4-cp310-cp310-win32.whl", hash = "sha256:b331bc0c3000cea1fd70febcf004b5a617c63b9050094f08100891a23638986d"},
{file = "ddtrace-3.12.4-cp310-cp310-win_amd64.whl", hash = "sha256:018d19e2a1e7585df65d938ae51c385d673e8001b66827a47e499ade3b227ad2"},
{file = "ddtrace-3.12.4-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:0de9563bad27007fd64059e3b5bb3a791184e39619fdb096044e68a454b4427b"},
{file = "ddtrace-3.12.4-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:d0c5b84d066ca3d60da9636df526382416dae4288f66fcdaca7a2e765ca2f0bd"},
{file = "ddtrace-3.12.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff1812b1d7e8344088a978f1d4f621257fe1ad5d8efc07317a3c90c280e5bdc4"},
{file = "ddtrace-3.12.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd0ac6ba50d36689bf0eeadc88ce91b60bc863036f3dea90dd5656f39bce3ac4"},
{file = "ddtrace-3.12.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8f99761f946b2b7cc2ea4cba821a7a94d05a9eb8cd8a3feabdb49eeacc18bb9"},
{file = "ddtrace-3.12.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c4f66c48eca7d6759766fcaf24ac3a65e712e62ae7b1f521a7da2b8d7f101849"},
{file = "ddtrace-3.12.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:42d46f17baaa5040e4f438544603033af8eeec32067c3712a9e620392d75f484"},
{file = "ddtrace-3.12.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa0606a07e7d05881f2ef1172f4175733ae3006bfc3c7cfd58b82ea3ed75c914"},
{file = "ddtrace-3.12.4-cp311-cp311-win32.whl", hash = "sha256:efde4b33502f3897993a564ee56d0ea30a65d658d616d16c5ef23c850d0e3417"},
{file = "ddtrace-3.12.4-cp311-cp311-win_amd64.whl", hash = "sha256:7d6117fabcd98d3a696d1f80314c9b9e4325b362b31714551efd729a02152ff1"},
{file = "ddtrace-3.12.4-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:734d782d9f64de378f632516554b9da0dfbf54cf1bb7be4bb1085165e7c052ad"},
{file = "ddtrace-3.12.4-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:fbf2543856b4ed5a1d6ac59c82f8c76cef5f4ef65361d59f60ce01db92a4c8d1"},
{file = "ddtrace-3.12.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:751ce0410405113286bd558fd402f8a58f5b455cee4deb467ae9ae87e5713547"},
{file = "ddtrace-3.12.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd804c06d62926cc18a354987f7d5c1fecd1da30983041d3f98bc402d9d23713"},
{file = "ddtrace-3.12.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e55b911d5b9f1bd73731870962809f9089677f4d3736d52587b4ba76eee56962"},
{file = "ddtrace-3.12.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8cc90fdcd7f021d06383b88c0e40726706c06088dddd528e31cf3c65a9fea9"},
{file = "ddtrace-3.12.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:585b7b26f03c64390c800e180304639b4226c34c533f16bc6cd9c328ee4f727a"},
{file = "ddtrace-3.12.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe967af58f2e0033caa977c512a4bfb7af3c6f5ad57e9bdef9241609a4d8a99b"},
{file = "ddtrace-3.12.4-cp312-cp312-win32.whl", hash = "sha256:fe03b8f513513e28c35bc792cd7ef0602b21cbcfe71d17a2dd962aee23e980d9"},
{file = "ddtrace-3.12.4-cp312-cp312-win_amd64.whl", hash = "sha256:9fd79c44ecffb36ac5b3168f0f196778ed0dd538beb07961ce10e06b8045af35"},
{file = "ddtrace-3.12.4-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:2edf755f4bfd823ce8b560c233cb17137ef79d097bc1ade7914f684b39011bcb"},
{file = "ddtrace-3.12.4-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:6dad7ca193810beb931e81b7430dd074a53bf8f8bd5bdc19acd198d460b2438a"},
{file = "ddtrace-3.12.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9de7aa6b6ea3d41f8f20c5e00dd85b2f2b3bb1591f3b7deab5d4c527620c3cb3"},
{file = "ddtrace-3.12.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80e0acbbe85365f113bf6e57f77a82f0e0612a7a4cb57f16e9e184748a2bc478"},
{file = "ddtrace-3.12.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46de7dd48256d8e347f2ab436644bd8946d3605caedb150eb46327a9f5b005b6"},
{file = "ddtrace-3.12.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d5c9ddacecb0072292360813b453129998ca293e13c542fa51771c7734ef03a"},
{file = "ddtrace-3.12.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d0b694838e6c7ea2da6de7ccd7b866ec439c49fa40b68ac46f657163cb571d93"},
{file = "ddtrace-3.12.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e89a17cdb4b5442b97a219e8522b9c665cf7a5116f7e97049dd145f837bad5b1"},
{file = "ddtrace-3.12.4-cp313-cp313-win32.whl", hash = "sha256:d0b3ec8228950e7ff68c39537630cd12880656d96461ef021d6484b2df8dba84"},
{file = "ddtrace-3.12.4-cp313-cp313-win_amd64.whl", hash = "sha256:fad78414731b242e86016a124299f2f41575ccf58444edca777b425dbd9faf0c"},
{file = "ddtrace-3.12.4-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:9f639f70f1689ec1a1049cd64132491ee09bcfe7609d73f8c220e38261611045"},
{file = "ddtrace-3.12.4-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:6b5b150e9d362f7242159dd5a5a7107f1be091282c0ee69301fb7ede60f28d3c"},
{file = "ddtrace-3.12.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda3b6ebd275f7f7272f45f4e8ee0e0720c1e217c80140270f8c5e415e11133e"},
{file = "ddtrace-3.12.4-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe644904b44d39a93eb40fb033aef26a03e4096d135ee844b71ed49d1bd647ad"},
{file = "ddtrace-3.12.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62a48fc36308919afb1fae22a268a96cff3448f1feb860db97d130498ddfa428"},
{file = "ddtrace-3.12.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:77de49365f55033d7e14b544f92d0cae71969b78c4ab8642c3340124e0200739"},
{file = "ddtrace-3.12.4-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:87fbd5126f8339bcb508a52455f58b0c92870a1c3748849a4d6543198b5f8752"},
{file = "ddtrace-3.12.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5845d7c2ed46b44e02bd5d36ca7f8e80a4e942683473c867393b9fd4553f9d64"},
{file = "ddtrace-3.12.4-cp38-cp38-win32.whl", hash = "sha256:ebde5af8c5d98f435d7dec960c97151142a4b302e94c20da79ed58fe8a08052e"},
{file = "ddtrace-3.12.4-cp38-cp38-win_amd64.whl", hash = "sha256:18dfe9a1a02bfa4ef4f614122135509f454abeff625039b764bc461462ba0923"},
{file = "ddtrace-3.12.4-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:e78957120c64bd56ce5592bc10587d7c0d1ca68f21f5b46f6a18dafbc43ad234"},
{file = "ddtrace-3.12.4-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:3936243dc989b8e8e3bb004262abe68a1cc3e0b9356671c01233b84d2c837903"},
{file = "ddtrace-3.12.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed76d10787fc288ea94808ce601df243fc3953c7142baefac446015bed799790"},
{file = "ddtrace-3.12.4-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c1d3f7f93146653f8ed06d8cd54030b2c902ceca6de55f6df7f40d23037181e"},
{file = "ddtrace-3.12.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f5ab24c82fc7532386b02530f90fed2964718cea296adf6d35fc31bd30d301d"},
{file = "ddtrace-3.12.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:30bd9e57923a99d5b4e6562976e9f7307d685caff1544b3d2f7438e6ef8e87e8"},
{file = "ddtrace-3.12.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3bf18fd5898940fb7f236b4c9796f0ee517eb755fd0c17965d3a0342f865ee5a"},
{file = "ddtrace-3.12.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8ff1c70da37c05a29f0be091b0fdc6bb1d91d448f56861c51df614649441070c"},
{file = "ddtrace-3.12.4-cp39-cp39-win32.whl", hash = "sha256:66c007170698e3d12638d03e80f02e93c3bb3e55e96a7f5517e638056562ec1a"},
{file = "ddtrace-3.12.4-cp39-cp39-win_amd64.whl", hash = "sha256:a4f2dabbc95e5c6bf4c43eb141e94021789c81a929588f4000f876f89882c124"},
{file = "ddtrace-3.12.4.tar.gz", hash = "sha256:c422977fc4f6e9ba7d4eef9b7e6ce00f8b81c68b034682c6a63eb5c9670e37d8"},
]
[package.dependencies]
@@ -2325,6 +2325,27 @@ gitdb = ">=4.0.1,<5"
doc = ["sphinx (>=7.1.2,<7.2)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"]
test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""]
[[package]]
name = "google-ai-generativelanguage"
version = "0.6.15"
description = "Google Ai Generativelanguage API client library"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "google_ai_generativelanguage-0.6.15-py3-none-any.whl", hash = "sha256:5a03ef86377aa184ffef3662ca28f19eeee158733e45d7947982eb953c6ebb6c"},
{file = "google_ai_generativelanguage-0.6.15.tar.gz", hash = "sha256:8f6d9dc4c12b065fe2d0289026171acea5183ebf2d0b11cefe12f3821e159ec3"},
]
[package.dependencies]
google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]}
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev"
proto-plus = [
{version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0dev"},
]
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev"
[[package]]
name = "google-api-core"
version = "2.25.1"
@@ -2663,6 +2684,30 @@ websockets = ">=13.0.0,<15.1.0"
[package.extras]
aiohttp = ["aiohttp (<4.0.0)"]
[[package]]
name = "google-generativeai"
version = "0.8.5"
description = "Google Generative AI High level API client library and tools."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "google_generativeai-0.8.5-py3-none-any.whl", hash = "sha256:22b420817fb263f8ed520b33285f45976d5b21e904da32b80d4fd20c055123a2"},
]
[package.dependencies]
google-ai-generativelanguage = "0.6.15"
google-api-core = "*"
google-api-python-client = "*"
google-auth = ">=2.15.0"
protobuf = "*"
pydantic = "*"
tqdm = "*"
typing-extensions = "*"
[package.extras]
dev = ["Pillow", "absl-py", "black", "ipython", "nose2", "pandas", "pytype", "pyyaml"]
[[package]]
name = "google-resumable-media"
version = "2.7.2"
@@ -5387,7 +5432,7 @@ google-api-python-client = "^2.164.0"
google-auth-httplib2 = "*"
google-auth-oauthlib = "*"
google-cloud-aiplatform = "*"
google-genai = "*"
google-generativeai = "*"
html2text = "*"
httpx-aiohttp = "^0.1.8"
ipywidgets = "^8.1.5"
@@ -5438,7 +5483,7 @@ whatthepatch = "^1.0.6"
zope-interface = "7.2"
[package.extras]
third-party-runtimes = ["daytona (==0.24.2)", "e2b-code-interpreter (>=2.0.0,<3.0.0)", "modal (>=0.66.26,<1.2.0)", "runloop-api-client (==0.50.0)"]
third-party-runtimes = ["daytona (==0.24.2)", "e2b (>=1.0.5,<1.8.0)", "modal (>=0.66.26,<1.2.0)", "runloop-api-client (==0.50.0)"]
[package.source]
type = "directory"
@@ -10008,4 +10053,4 @@ cffi = ["cffi (>=1.17) ; python_version >= \"3.13\" and platform_python_implemen
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "5771671ef2acc36e7b0931c73fa035ca1d329e8dac6827f7a349e1a569c3fd23"
content-hash = "0e611931bd3823ee8b6d832b6ef444868a644e21927a9fb72d4aeaab8170028e"

View File

@@ -37,7 +37,7 @@ sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" }
resend = "^2.7.0"
tenacity = "^9.1.2"
slack-sdk = "^3.35.0"
ddtrace = "3.13.0" #pin to avoid yanked version 3.12.4
ddtrace = "^3.5.1"
posthog = "^4.2.0"
limits = "^5.2.0"
coredis = "^4.22.0"

View File

@@ -275,7 +275,9 @@ class TokenManager:
self._check_expiration_and_refresh
)
if not token_info:
logger.info(f'No tokens for user: {username}, identity provider: {idp}')
logger.error(
f'No tokens for user: {username}, identity provider: {idp}'
)
raise ValueError(
f'No tokens for user: {username}, identity provider: {idp}'
)

View File

@@ -17,13 +17,11 @@ from server.constants import (
STRIPE_API_KEY,
STRIPE_WEBHOOK_SECRET,
SUBSCRIPTION_PRICE_DATA,
get_default_litellm_model,
)
from server.logger import logger
from storage.billing_session import BillingSession
from storage.database import session_maker
from storage.subscription_access import SubscriptionAccess
from storage.user_settings import UserSettings
from openhands.server.user_auth import get_user_id
@@ -44,8 +42,6 @@ class SubscriptionAccessResponse(BaseModel):
start_at: datetime
end_at: datetime
created_at: datetime
cancelled_at: datetime | None = None
stripe_subscription_id: str | None = None
class CreateCheckoutSessionRequest(BaseModel):
@@ -89,7 +85,7 @@ async def get_credits(user_id: str = Depends(get_user_id)) -> GetCreditsResponse
async def get_subscription_access(
user_id: str = Depends(get_user_id),
) -> SubscriptionAccessResponse | None:
"""Get details of the currently valid subscription for the user."""
"""Get details of the currently valid subscription for the user"""
with session_maker() as session:
now = datetime.now(UTC)
subscription_access = (
@@ -106,8 +102,6 @@ async def get_subscription_access(
start_at=subscription_access.start_at,
end_at=subscription_access.end_at,
created_at=subscription_access.created_at,
cancelled_at=subscription_access.cancelled_at,
stripe_subscription_id=subscription_access.stripe_subscription_id,
)
@@ -119,78 +113,6 @@ async def has_payment_method(user_id: str = Depends(get_user_id)) -> bool:
return await stripe_service.has_payment_method(user_id)
# Endpoint to cancel user's subscription
@billing_router.post('/cancel-subscription')
async def cancel_subscription(user_id: str = Depends(get_user_id)) -> JSONResponse:
"""Cancel user's active subscription at the end of the current billing period."""
if not user_id:
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
with session_maker() as session:
# Find the user's active subscription
now = datetime.now(UTC)
subscription_access = (
session.query(SubscriptionAccess)
.filter(SubscriptionAccess.status == 'ACTIVE')
.filter(SubscriptionAccess.user_id == user_id)
.filter(SubscriptionAccess.start_at <= now)
.filter(SubscriptionAccess.end_at >= now)
.filter(SubscriptionAccess.cancelled_at.is_(None)) # Not already cancelled
.first()
)
if not subscription_access:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No active subscription found',
)
if not subscription_access.stripe_subscription_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot cancel subscription: missing Stripe subscription ID',
)
try:
# Cancel the subscription in Stripe at period end
await stripe.Subscription.modify_async(
subscription_access.stripe_subscription_id, cancel_at_period_end=True
)
# Update local database
subscription_access.cancelled_at = datetime.now(UTC)
session.merge(subscription_access)
session.commit()
logger.info(
'subscription_cancelled',
extra={
'user_id': user_id,
'stripe_subscription_id': subscription_access.stripe_subscription_id,
'subscription_access_id': subscription_access.id,
'end_at': subscription_access.end_at,
},
)
return JSONResponse(
{'status': 'success', 'message': 'Subscription cancelled successfully'}
)
except stripe.StripeError as e:
logger.error(
'stripe_cancellation_failed',
extra={
'user_id': user_id,
'stripe_subscription_id': subscription_access.stripe_subscription_id,
'error': str(e),
},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to cancel subscription: {str(e)}',
)
# Endpoint to create a new setup intent in stripe
@billing_router.post('/create-customer-setup-session')
async def create_customer_setup_session(
@@ -268,27 +190,9 @@ async def create_subscription_checkout_session(
billing_session_type: BillingSessionType = BillingSessionType.MONTHLY_SUBSCRIPTION,
user_id: str = Depends(get_user_id),
) -> CreateBillingSessionResponse:
# Prevent duplicate subscriptions for the same user
with session_maker() as session:
now = datetime.now(UTC)
existing_active_subscription = (
session.query(SubscriptionAccess)
.filter(SubscriptionAccess.status == 'ACTIVE')
.filter(SubscriptionAccess.user_id == user_id)
.filter(SubscriptionAccess.start_at <= now)
.filter(SubscriptionAccess.end_at >= now)
.filter(SubscriptionAccess.cancelled_at.is_(None)) # Not cancelled
.first()
)
if existing_active_subscription:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot create subscription: User already has an active subscription that has not been cancelled',
)
customer_id = await stripe_service.find_or_create_customer(user_id)
subscription_price_data = SUBSCRIPTION_PRICE_DATA[billing_session_type.value]
# TODO: Prevent duplicate subscriptions for the same user
checkout_session = await stripe.checkout.Session.create_async(
customer=customer_id,
line_items=[
@@ -342,7 +246,7 @@ async def create_subscription_checkout_session_via_get(
billing_session_type: BillingSessionType = BillingSessionType.MONTHLY_SUBSCRIPTION,
user_id: str = Depends(get_user_id),
) -> RedirectResponse:
"""Create a subscription checkout session using a GET request (For easier copy / paste to URL bar)."""
"""Create a subscription checkout session using a GET request (For easier copy / paste to URL bar)"""
response = await create_subscription_checkout_session(
request, billing_session_type, user_id
)
@@ -374,7 +278,7 @@ async def success_callback(session_id: str, request: Request):
!= BillingSessionType.DIRECT_PAYMENT.value
):
return RedirectResponse(
f'{request.base_url}settings?checkout=success', status_code=302
f'{request.base_url}settings/billing?checkout=success', status_code=302
)
stripe_session = stripe.checkout.Session.retrieve(session_id)
@@ -444,29 +348,14 @@ async def cancel_callback(session_id: str, request: Request):
session.merge(billing_session)
session.commit()
# Redirect credit purchases to billing screen, subscriptions to LLM settings
if (
billing_session.billing_session_type
== BillingSessionType.DIRECT_PAYMENT.value
):
return RedirectResponse(
f'{request.base_url}settings/billing?checkout=cancel',
status_code=302,
)
else:
return RedirectResponse(
f'{request.base_url}settings?checkout=cancel', status_code=302
)
# If no billing session found, default to LLM settings (subscription flow)
return RedirectResponse(
f'{request.base_url}settings?checkout=cancel', status_code=302
f'{request.base_url}settings/billing?checkout=cancel', status_code=302
)
@billing_router.post('/stripe-webhook')
async def stripe_webhook(request: Request) -> JSONResponse:
"""Endpoint for stripe webhooks."""
"""Endpoint for stripe webhooks"""
payload = await request.body()
sig_header = request.headers.get('stripe-signature')
@@ -508,111 +397,15 @@ async def stripe_webhook(request: Request) -> JSONResponse:
end_at=end_at,
amount_paid=amount_paid,
stripe_invoice_payment_id=invoice.payment_intent,
stripe_subscription_id=invoice.subscription, # Store Stripe subscription ID
)
session.add(subscription_access)
session.commit()
elif event_type == 'customer.subscription.updated':
subscription = event['data']['object']
subscription_id = subscription['id']
# Handle subscription cancellation
if subscription.get('cancel_at_period_end') is True:
with session_maker() as session:
subscription_access = (
session.query(SubscriptionAccess)
.filter(
SubscriptionAccess.stripe_subscription_id == subscription_id
)
.filter(SubscriptionAccess.status == 'ACTIVE')
.first()
)
if subscription_access and not subscription_access.cancelled_at:
subscription_access.cancelled_at = datetime.now(UTC)
session.merge(subscription_access)
session.commit()
logger.info(
'subscription_cancelled_via_webhook',
extra={
'stripe_subscription_id': subscription_id,
'user_id': subscription_access.user_id,
'subscription_access_id': subscription_access.id,
},
)
elif event_type == 'customer.subscription.deleted':
subscription = event['data']['object']
subscription_id = subscription['id']
with session_maker() as session:
subscription_access = (
session.query(SubscriptionAccess)
.filter(SubscriptionAccess.stripe_subscription_id == subscription_id)
.filter(SubscriptionAccess.status == 'ACTIVE')
.first()
)
if subscription_access:
subscription_access.status = 'DISABLED'
subscription_access.updated_at = datetime.now(UTC)
session.merge(subscription_access)
session.commit()
# Reset user settings to free tier defaults
reset_user_to_free_tier_settings(subscription_access.user_id)
logger.info(
'subscription_expired_reset_to_free_tier',
extra={
'stripe_subscription_id': subscription_id,
'user_id': subscription_access.user_id,
'subscription_access_id': subscription_access.id,
},
)
else:
logger.info('stripe_webhook_unhandled_event_type', extra={'type': event_type})
return JSONResponse({'status': 'success'})
def reset_user_to_free_tier_settings(user_id: str) -> None:
"""Reset user settings to free tier defaults when subscription ends."""
with session_maker() as session:
user_settings = (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == user_id)
.first()
)
if user_settings:
user_settings.llm_model = get_default_litellm_model()
user_settings.llm_api_key = None
user_settings.llm_api_key_for_byor = None
user_settings.llm_base_url = LITE_LLM_API_URL
user_settings.max_budget_per_task = None
user_settings.confirmation_mode = False
user_settings.enable_solvability_analysis = False
user_settings.security_analyzer = 'llm'
user_settings.agent = 'CodeActAgent'
user_settings.language = 'en'
user_settings.enable_default_condenser = True
user_settings.enable_sound_notifications = False
user_settings.enable_proactive_conversation_starters = True
user_settings.user_consents_to_analytics = False
session.merge(user_settings)
session.commit()
logger.info(
'user_settings_reset_to_free_tier',
extra={
'user_id': user_id,
'reset_timestamp': datetime.now(UTC).isoformat(),
},
)
async def _get_litellm_user(client: httpx.AsyncClient, user_id: str) -> dict:
"""Get a user from litellm with the id matching that given.

View File

@@ -234,7 +234,7 @@ def _get_user_id(conversation_id: str) -> str:
return conversation_metadata.user_id
async def _get_session_api_key(user_id: str, conversation_id: str) -> str | None:
async def _get_session_api_key(user_id: str, conversation_id: str) -> str:
agent_loop_info = await conversation_manager.get_agent_loop_info(
user_id, filter_to_sids={conversation_id}
)

View File

@@ -7,7 +7,7 @@ from storage.base import Base
class SubscriptionAccess(Base): # type: ignore
"""
Represents a user's subscription access record.
Tracks subscription status, duration, payment information, and cancellation status.
Tracks subscription status, duration, and payment information.
"""
__tablename__ = 'subscription_access'
@@ -27,8 +27,6 @@ class SubscriptionAccess(Base): # type: ignore
end_at = Column(DateTime(timezone=True), nullable=True)
amount_paid = Column(DECIMAL(19, 4), nullable=True)
stripe_invoice_payment_id = Column(String, nullable=False)
cancelled_at = Column(DateTime(timezone=True), nullable=True)
stripe_subscription_id = Column(String, nullable=True, index=True)
created_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]

View File

@@ -276,12 +276,12 @@ class VerifyWebhookStatus:
webhook
)
gitlab_service_impl = GitLabServiceImpl(external_auth_id=user_id)
gitlab_service = GitLabServiceImpl(external_auth_id=user_id)
if not isinstance(gitlab_service_impl, SaaSGitLabService):
if not isinstance(gitlab_service, SaaSGitLabService):
raise Exception('Only SaaSGitLabService is supported')
# Cast needed when mypy can see OpenHands
gitlab_service = cast(type[SaaSGitLabService], gitlab_service_impl)
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
await self.verify_conditions_are_met(
gitlab_service=gitlab_service,

View File

@@ -1,159 +0,0 @@
"""Tests for enterprise integrations utils module."""
import pytest
from integrations.utils import get_summary_for_agent_state
from openhands.core.schema.agent import AgentState
from openhands.events.observation.agent import AgentStateChangedObservation
class TestGetSummaryForAgentState:
"""Test cases for get_summary_for_agent_state function."""
def setup_method(self):
"""Set up test fixtures."""
self.conversation_link = 'https://example.com/conversation/123'
def test_empty_observations_list(self):
"""Test handling of empty observations list."""
result = get_summary_for_agent_state([], self.conversation_link)
assert 'unknown error' in result.lower()
assert self.conversation_link in result
@pytest.mark.parametrize(
'state,expected_text,includes_link',
[
(AgentState.RATE_LIMITED, 'rate limited', False),
(AgentState.AWAITING_USER_INPUT, 'waiting for your input', True),
],
)
def test_handled_agent_states(self, state, expected_text, includes_link):
"""Test handling of states with specific behavior."""
observation = AgentStateChangedObservation(
content=f'Agent state: {state.value}', agent_state=state
)
result = get_summary_for_agent_state([observation], self.conversation_link)
assert expected_text in result.lower()
if includes_link:
assert self.conversation_link in result
else:
assert self.conversation_link not in result
@pytest.mark.parametrize(
'state',
[
AgentState.FINISHED,
AgentState.PAUSED,
AgentState.STOPPED,
AgentState.AWAITING_USER_CONFIRMATION,
],
)
def test_unhandled_agent_states(self, state):
"""Test handling of unhandled states (should all return unknown error)."""
observation = AgentStateChangedObservation(
content=f'Agent state: {state.value}', agent_state=state
)
result = get_summary_for_agent_state([observation], self.conversation_link)
assert 'unknown error' in result.lower()
assert self.conversation_link in result
@pytest.mark.parametrize(
'error_code,expected_text',
[
(
'STATUS$ERROR_LLM_AUTHENTICATION',
'authentication with the llm provider failed',
),
(
'STATUS$ERROR_LLM_SERVICE_UNAVAILABLE',
'llm service is temporarily unavailable',
),
(
'STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR',
'llm provider encountered an internal error',
),
('STATUS$ERROR_LLM_OUT_OF_CREDITS', "you've run out of credits"),
('STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION', 'content policy violation'),
],
)
def test_error_state_readable_reasons(self, error_code, expected_text):
"""Test all readable error reason mappings."""
observation = AgentStateChangedObservation(
content=f'Agent encountered error: {error_code}',
agent_state=AgentState.ERROR,
reason=error_code,
)
result = get_summary_for_agent_state([observation], self.conversation_link)
assert 'encountered an error' in result.lower()
assert expected_text in result.lower()
assert self.conversation_link in result
def test_error_state_with_custom_reason(self):
"""Test handling of ERROR state with a custom reason."""
observation = AgentStateChangedObservation(
content='Agent encountered an error',
agent_state=AgentState.ERROR,
reason='Test error message',
)
result = get_summary_for_agent_state([observation], self.conversation_link)
assert 'encountered an error' in result.lower()
assert 'test error message' in result.lower()
assert self.conversation_link in result
def test_multiple_observations_uses_first(self):
"""Test that when multiple observations are provided, only the first is used."""
observation1 = AgentStateChangedObservation(
content='Agent is awaiting user input',
agent_state=AgentState.AWAITING_USER_INPUT,
)
observation2 = AgentStateChangedObservation(
content='Agent encountered an error',
agent_state=AgentState.ERROR,
reason='Should not be used',
)
result = get_summary_for_agent_state(
[observation1, observation2], self.conversation_link
)
# Should handle the first observation (AWAITING_USER_INPUT), not the second (ERROR)
assert 'waiting for your input' in result.lower()
assert 'error' not in result.lower()
def test_awaiting_user_input_specific_message(self):
"""Test that AWAITING_USER_INPUT returns the specific expected message."""
observation = AgentStateChangedObservation(
content='Agent is awaiting user input',
agent_state=AgentState.AWAITING_USER_INPUT,
)
result = get_summary_for_agent_state([observation], self.conversation_link)
# Test the exact message format
assert 'waiting for your input' in result.lower()
assert 'continue the conversation' in result.lower()
assert self.conversation_link in result
assert 'unknown error' not in result.lower()
def test_rate_limited_specific_message(self):
"""Test that RATE_LIMITED returns the specific expected message."""
observation = AgentStateChangedObservation(
content='Agent was rate limited', agent_state=AgentState.RATE_LIMITED
)
result = get_summary_for_agent_state([observation], self.conversation_link)
# Test the exact message format
assert 'rate limited' in result.lower()
assert 'try again later' in result.lower()
# RATE_LIMITED doesn't include conversation link in response
assert self.conversation_link not in result

View File

@@ -5,16 +5,16 @@ import pytest
import stripe
from fastapi import HTTPException, Request, status
from httpx import HTTPStatusError, Response
from integrations.stripe_service import has_payment_method
from server.routes import billing
from server.routes.billing import (
CreateBillingSessionResponse,
CreateCheckoutSessionRequest,
GetCreditsResponse,
cancel_callback,
cancel_subscription,
create_checkout_session,
create_subscription_checkout_session,
create_customer_setup_session,
get_credits,
has_payment_method,
success_callback,
)
from sqlalchemy import create_engine
@@ -362,7 +362,8 @@ async def test_cancel_callback_session_not_found():
response = await cancel_callback('test_session_id', mock_request)
assert response.status_code == 302
assert (
response.headers['location'] == 'http://test.com/settings?checkout=cancel'
response.headers['location']
== 'http://test.com/settings/billing?checkout=cancel'
)
# Verify no database updates occurred
@@ -388,7 +389,8 @@ async def test_cancel_callback_success():
assert response.status_code == 302
assert (
response.headers['location'] == 'http://test.com/settings?checkout=cancel'
response.headers['location']
== 'http://test.com/settings/billing?checkout=cancel'
)
# Verify database updates
@@ -400,312 +402,51 @@ async def test_cancel_callback_success():
@pytest.mark.asyncio
async def test_has_payment_method_with_payment_method():
"""Test has_payment_method returns True when user has a payment method."""
with (
patch('integrations.stripe_service.session_maker') as mock_session_maker,
patch(
'stripe.Customer.list_payment_methods_async',
AsyncMock(return_value=MagicMock(data=[MagicMock()])),
) as mock_list_payment_methods,
):
# Setup mock session
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_session.query.return_value.filter.return_value.first.return_value = (
MagicMock(stripe_customer_id='cus_test123')
)
mock_has_payment_method = AsyncMock(return_value=True)
with patch(
'integrations.stripe_service.has_payment_method', mock_has_payment_method
):
result = await has_payment_method('mock_user')
assert result is True
mock_list_payment_methods.assert_called_once_with('cus_test123')
mock_has_payment_method.assert_called_once_with('mock_user')
@pytest.mark.asyncio
async def test_has_payment_method_without_payment_method():
"""Test has_payment_method returns False when user has no payment method."""
with (
patch('integrations.stripe_service.session_maker') as mock_session_maker,
patch(
'stripe.Customer.list_payment_methods_async',
AsyncMock(return_value=MagicMock(data=[])),
) as mock_list_payment_methods,
mock_has_payment_method = AsyncMock(return_value=False)
with patch(
'integrations.stripe_service.has_payment_method', mock_has_payment_method
):
# Setup mock session
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_session.query.return_value.filter.return_value.first.return_value = (
MagicMock(stripe_customer_id='cus_test123')
)
mock_has_payment_method.return_value = False
result = await has_payment_method('mock_user')
assert result is False
mock_list_payment_methods.assert_called_once_with('cus_test123')
mock_has_payment_method.assert_called_once_with('mock_user')
@pytest.mark.asyncio
async def test_cancel_subscription_success():
"""Test successful subscription cancellation."""
from datetime import UTC, datetime
from storage.subscription_access import SubscriptionAccess
# Mock active subscription
mock_subscription_access = SubscriptionAccess(
id=1,
status='ACTIVE',
user_id='test_user',
start_at=datetime.now(UTC),
end_at=datetime.now(UTC),
amount_paid=2000,
stripe_invoice_payment_id='pi_test',
stripe_subscription_id='sub_test123',
cancelled_at=None,
async def test_create_customer_setup_session_success():
"""Test successful creation of customer setup session."""
mock_request = Request(
scope={'type': 'http', 'state': {'user_id': 'mock_user'}, 'headers': []}
)
# Mock Stripe subscription response
mock_stripe_subscription = MagicMock()
mock_stripe_subscription.cancel_at_period_end = True
with (
patch('server.routes.billing.session_maker') as mock_session_maker,
patch(
'stripe.Subscription.modify_async',
AsyncMock(return_value=mock_stripe_subscription),
) as mock_stripe_modify,
):
# Setup mock session
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_session.query.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.first.return_value = mock_subscription_access
# Call the function
result = await cancel_subscription('test_user')
# Verify Stripe API was called
mock_stripe_modify.assert_called_once_with(
'sub_test123', cancel_at_period_end=True
)
# Verify database was updated
assert mock_subscription_access.cancelled_at is not None
mock_session.merge.assert_called_once_with(mock_subscription_access)
mock_session.commit.assert_called_once()
# Verify response
assert result.status_code == 200
@pytest.mark.asyncio
async def test_cancel_subscription_no_active_subscription():
"""Test cancellation when no active subscription exists."""
with (
patch('server.routes.billing.session_maker') as mock_session_maker,
):
# Setup mock session with no subscription found
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_session.query.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.first.return_value = None
# Call the function and expect HTTPException
with pytest.raises(HTTPException) as exc_info:
await cancel_subscription('test_user')
assert exc_info.value.status_code == 404
assert 'No active subscription found' in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_cancel_subscription_missing_stripe_id():
"""Test cancellation when subscription has no Stripe ID."""
from datetime import UTC, datetime
from storage.subscription_access import SubscriptionAccess
# Mock subscription without Stripe ID
mock_subscription_access = SubscriptionAccess(
id=1,
status='ACTIVE',
user_id='test_user',
start_at=datetime.now(UTC),
end_at=datetime.now(UTC),
amount_paid=2000,
stripe_invoice_payment_id='pi_test',
stripe_subscription_id=None, # Missing Stripe ID
cancelled_at=None,
mock_customer = stripe.Customer(
id='mock-customer', metadata={'user_id': 'mock-user'}
)
mock_session = MagicMock()
mock_session.url = 'https://checkout.stripe.com/test-session'
mock_create = AsyncMock(return_value=mock_session)
with (
patch('server.routes.billing.session_maker') as mock_session_maker,
):
# Setup mock session
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_session.query.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.first.return_value = mock_subscription_access
# Call the function and expect HTTPException
with pytest.raises(HTTPException) as exc_info:
await cancel_subscription('test_user')
assert exc_info.value.status_code == 400
assert 'missing Stripe subscription ID' in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_cancel_subscription_stripe_error():
"""Test cancellation when Stripe API fails."""
from datetime import UTC, datetime
from storage.subscription_access import SubscriptionAccess
# Mock active subscription
mock_subscription_access = SubscriptionAccess(
id=1,
status='ACTIVE',
user_id='test_user',
start_at=datetime.now(UTC),
end_at=datetime.now(UTC),
amount_paid=2000,
stripe_invoice_payment_id='pi_test',
stripe_subscription_id='sub_test123',
cancelled_at=None,
)
with (
patch('server.routes.billing.session_maker') as mock_session_maker,
patch(
'stripe.Subscription.modify_async',
AsyncMock(side_effect=stripe.StripeError('API Error')),
),
):
# Setup mock session
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_session.query.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.first.return_value = mock_subscription_access
# Call the function and expect HTTPException
with pytest.raises(HTTPException) as exc_info:
await cancel_subscription('test_user')
assert exc_info.value.status_code == 500
assert 'Failed to cancel subscription' in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_create_subscription_checkout_session_duplicate_prevention():
"""Test that creating a subscription when user already has active subscription raises error."""
from datetime import UTC, datetime
from storage.subscription_access import SubscriptionAccess
# Mock active subscription
mock_subscription_access = SubscriptionAccess(
id=1,
status='ACTIVE',
user_id='test_user',
start_at=datetime.now(UTC),
end_at=datetime.now(UTC),
amount_paid=2000,
stripe_invoice_payment_id='pi_test',
stripe_subscription_id='sub_test123',
cancelled_at=None,
)
mock_request = Request(scope={'type': 'http'})
mock_request._base_url = URL('http://test.com/')
with (
patch('server.routes.billing.session_maker') as mock_session_maker,
):
# Setup mock session to return existing active subscription
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_session.query.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.first.return_value = mock_subscription_access
# Call the function and expect HTTPException
with pytest.raises(HTTPException) as exc_info:
await create_subscription_checkout_session(
mock_request, user_id='test_user'
)
assert exc_info.value.status_code == 400
assert (
'user already has an active subscription'
in str(exc_info.value.detail).lower()
)
@pytest.mark.asyncio
async def test_create_subscription_checkout_session_allows_after_cancellation():
"""Test that creating a subscription is allowed when previous subscription was cancelled."""
mock_request = Request(scope={'type': 'http'})
mock_request._base_url = URL('http://test.com/')
mock_session_obj = MagicMock()
mock_session_obj.url = 'https://checkout.stripe.com/test-session'
mock_session_obj.id = 'test_session_id'
with (
patch('server.routes.billing.session_maker') as mock_session_maker,
patch(
'integrations.stripe_service.find_or_create_customer',
AsyncMock(return_value='cus_test123'),
),
patch(
'stripe.checkout.Session.create_async',
AsyncMock(return_value=mock_session_obj),
),
patch(
'server.routes.billing.SUBSCRIPTION_PRICE_DATA',
{'MONTHLY_SUBSCRIPTION': {'unit_amount': 2000}},
AsyncMock(return_value=mock_customer),
),
patch('stripe.checkout.Session.create_async', mock_create),
):
# Setup mock session - the query should return None because cancelled subscriptions are filtered out
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_session.query.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.first.return_value = None
result = await create_customer_setup_session(mock_request)
# Should succeed
result = await create_subscription_checkout_session(
mock_request, user_id='test_user'
)
assert isinstance(result, CreateBillingSessionResponse)
assert result.redirect_url == 'https://checkout.stripe.com/test-session'
@pytest.mark.asyncio
async def test_create_subscription_checkout_session_success_no_existing():
"""Test successful subscription creation when no existing subscription."""
mock_request = Request(scope={'type': 'http'})
mock_request._base_url = URL('http://test.com/')
mock_session_obj = MagicMock()
mock_session_obj.url = 'https://checkout.stripe.com/test-session'
mock_session_obj.id = 'test_session_id'
with (
patch('server.routes.billing.session_maker') as mock_session_maker,
patch(
'integrations.stripe_service.find_or_create_customer',
AsyncMock(return_value='cus_test123'),
),
patch(
'stripe.checkout.Session.create_async',
AsyncMock(return_value=mock_session_obj),
),
patch(
'server.routes.billing.SUBSCRIPTION_PRICE_DATA',
{'MONTHLY_SUBSCRIPTION': {'unit_amount': 2000}},
),
):
# Setup mock session to return no existing subscription
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_session.query.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.first.return_value = None
# Should succeed
result = await create_subscription_checkout_session(
mock_request, user_id='test_user'
)
assert isinstance(result, CreateBillingSessionResponse)
assert isinstance(result, billing.CreateBillingSessionResponse)
assert result.redirect_url == 'https://checkout.stripe.com/test-session'

View File

@@ -28,7 +28,6 @@ from evaluation.utils.shared import (
prepare_dataset,
reset_logger_for_multiprocessing,
run_evaluation,
update_llm_config_for_completions_logging,
)
from openhands.controller.state.state import State
from openhands.core.config import (
@@ -37,11 +36,7 @@ from openhands.core.config import (
get_llm_config_arg,
load_from_toml,
)
from openhands.core.config.utils import (
get_agent_config_arg,
get_llms_for_routing_config,
get_model_routing_config_arg,
)
from openhands.core.config.utils import get_agent_config_arg
from openhands.core.logger import openhands_logger as logger
from openhands.core.main import create_runtime, run_controller
from openhands.events.action import AgentFinishAction, CmdRunAction, MessageAction
@@ -62,7 +57,6 @@ AGENT_CLS_TO_INST_SUFFIX = {
def get_config(
instance: pd.Series,
metadata: EvalMetadata,
) -> OpenHandsConfig:
sandbox_config = get_default_sandbox_config_for_eval()
@@ -72,24 +66,13 @@ def get_config(
sandbox_config=sandbox_config,
runtime='docker',
)
config.set_llm_config(
update_llm_config_for_completions_logging(
metadata.llm_config, metadata.eval_output_dir, instance['instance_id']
)
)
model_routing_config = get_model_routing_config_arg()
model_routing_config.llms_for_routing = (
get_llms_for_routing_config()
) # Populate with LLMs for routing from config.toml file
config.set_llm_config(metadata.llm_config)
if metadata.agent_config:
metadata.agent_config.model_routing = model_routing_config
config.set_agent_config(metadata.agent_config, metadata.agent_class)
else:
logger.info('Agent config not provided, using default settings')
agent_config = config.get_agent_config(metadata.agent_class)
agent_config.enable_prompt_extensions = False
agent_config.model_routing = model_routing_config
config_copy = copy.deepcopy(config)
load_from_toml(config_copy)
@@ -162,7 +145,7 @@ def process_instance(
metadata: EvalMetadata,
reset_logger: bool = True,
) -> EvalOutput:
config = get_config(instance, metadata)
config = get_config(metadata)
# Setup the logger properly, so you can run multi-processing to parallelize the evaluation
if reset_logger:

View File

@@ -47,8 +47,6 @@ from openhands.core.config import (
get_agent_config_arg,
get_evaluation_parser,
get_llm_config_arg,
get_llms_for_routing_config,
get_model_routing_config_arg,
)
from openhands.core.config.condenser_config import NoOpCondenserConfig
from openhands.core.config.utils import get_condenser_config_arg
@@ -246,11 +244,6 @@ def get_config(
# get 'draft_editor' config if exists
config.set_llm_config(get_llm_config_arg('draft_editor'), 'draft_editor')
model_routing_config = get_model_routing_config_arg()
model_routing_config.llms_for_routing = (
get_llms_for_routing_config()
) # Populate with LLMs for routing from config.toml file
agent_config = AgentConfig(
enable_jupyter=False,
enable_browsing=RUN_WITH_BROWSING,
@@ -258,10 +251,8 @@ def get_config(
enable_mcp=False,
condenser=metadata.condenser_config,
enable_prompt_extensions=False,
model_routing=model_routing_config,
)
config.set_agent_config(agent_config)
return config

View File

@@ -1,6 +1,8 @@
# Run frontend checks
echo "Running frontend checks..."
cd frontend
npm run lint
npm run check-translation-completeness
npx lint-staged
# Run backend pre-commit

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import OpenHands from "#/api/open-hands";
import {
FILE_VARIANTS_1,
FILE_VARIANTS_2,
@@ -10,20 +10,20 @@ import {
* You can find the mock handlers in `frontend/src/mocks/file-service-handlers.ts`.
*/
describe("ConversationService File API", () => {
describe("OpenHands File API", () => {
it("should get a list of files", async () => {
await expect(
ConversationService.getFiles("test-conversation-id"),
).resolves.toEqual(FILE_VARIANTS_1);
await expect(OpenHands.getFiles("test-conversation-id")).resolves.toEqual(
FILE_VARIANTS_1,
);
await expect(
ConversationService.getFiles("test-conversation-id-2"),
OpenHands.getFiles("test-conversation-id-2"),
).resolves.toEqual(FILE_VARIANTS_2);
});
it("should get content of a file", async () => {
await expect(
ConversationService.getFile("test-conversation-id", "file1.txt"),
OpenHands.getFile("test-conversation-id", "file1.txt"),
).resolves.toEqual("Content of file1.txt");
});
});

View File

@@ -0,0 +1,287 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ActionSuggestions } from "#/components/features/chat/action-suggestions";
import OpenHands from "#/api/open-hands";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
// Mock dependencies
vi.mock("posthog-js", () => ({
default: {
capture: vi.fn(),
},
}));
const { useSelectorMock } = vi.hoisted(() => ({
useSelectorMock: vi.fn(),
}));
vi.mock("react-redux", () => ({
useSelector: useSelectorMock,
}));
vi.mock("#/context/auth-context", () => ({
useAuth: vi.fn(),
}));
// Mock react-i18next
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
ACTION$PUSH_TO_BRANCH: "Push to Branch",
ACTION$PUSH_CREATE_PR: "Push & Create PR",
ACTION$PUSH_CHANGES_TO_PR: "Push Changes to PR",
};
return translations[key] || key;
},
}),
}));
vi.mock("react-router", () => ({
useParams: () => ({
conversationId: "test-conversation-id",
}),
}));
const renderActionSuggestions = () =>
render(<ActionSuggestions onSuggestionsClick={() => {}} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
describe("ActionSuggestions", () => {
// Setup mocks for each test
beforeEach(() => {
vi.clearAllMocks();
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: "some-token",
},
});
useSelectorMock.mockReturnValue({
selectedRepository: "test-repo",
});
});
it("should render both GitHub buttons when GitHub token is set and repository is selected", async () => {
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
// @ts-expect-error - only required for testing
getConversationSpy.mockResolvedValue({
selected_repository: "test-repo",
});
renderActionSuggestions();
// Find all buttons with data-testid="suggestion"
const buttons = await screen.findAllByTestId("suggestion");
// Check if we have at least 2 buttons
expect(buttons.length).toBeGreaterThanOrEqual(2);
// Check if the buttons contain the expected text
const pushButton = buttons.find((button) =>
button.textContent?.includes("Push to Branch"),
);
const prButton = buttons.find((button) =>
button.textContent?.includes("Push & Create PR"),
);
expect(pushButton).toBeInTheDocument();
expect(prButton).toBeInTheDocument();
});
it("should not render buttons when GitHub token is not set", () => {
renderActionSuggestions();
expect(screen.queryByTestId("suggestion")).not.toBeInTheDocument();
});
it("should not render buttons when no repository is selected", () => {
useSelectorMock.mockReturnValue({
selectedRepository: null,
});
renderActionSuggestions();
expect(screen.queryByTestId("suggestion")).not.toBeInTheDocument();
});
it("should have different prompts for 'Push to Branch' and 'Push & Create PR' buttons", () => {
// This test verifies that the prompts are different in the component
renderActionSuggestions();
// Get the component instance to access the internal values
const pushBranchPrompt =
"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.";
const createPRPrompt =
"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.";
// Verify the prompts are different
expect(pushBranchPrompt).not.toEqual(createPRPrompt);
// Verify the PR prompt mentions creating a meaningful branch name
expect(createPRPrompt).toContain("meaningful branch name");
expect(createPRPrompt).not.toContain("SAME branch name");
});
it("should use correct provider name based on conversation git_provider, not user authenticated providers", async () => {
// Test case for GitHub repository
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
getConversationSpy.mockResolvedValue({
conversation_id: "test-github",
title: "GitHub Test",
selected_repository: "test-repo",
git_provider: "github",
selected_branch: "main",
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null,
});
// Mock user having both GitHub and Bitbucket tokens
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: "github-token",
bitbucket: "bitbucket-token",
},
});
const onSuggestionsClick = vi.fn();
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
const buttons = await screen.findAllByTestId("suggestion");
const prButton = buttons.find((button) =>
button.textContent?.includes("Push & Create PR"),
);
expect(prButton).toBeInTheDocument();
if (prButton) {
prButton.click();
}
// The suggestion should mention GitHub, not Bitbucket
expect(onSuggestionsClick).toHaveBeenCalledWith(
expect.stringContaining("GitHub")
);
expect(onSuggestionsClick).not.toHaveBeenCalledWith(
expect.stringContaining("Bitbucket")
);
});
it("should use GitLab terminology when git_provider is gitlab", async () => {
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
getConversationSpy.mockResolvedValue({
conversation_id: "test-gitlab",
title: "GitLab Test",
selected_repository: "test-repo",
git_provider: "gitlab",
selected_branch: "main",
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null,
});
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
gitlab: "gitlab-token",
},
});
const onSuggestionsClick = vi.fn();
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
const buttons = await screen.findAllByTestId("suggestion");
const prButton = buttons.find((button) =>
button.textContent?.includes("Push & Create PR"),
);
if (prButton) {
prButton.click();
}
// Should mention GitLab and "merge request" instead of "pull request"
expect(onSuggestionsClick).toHaveBeenCalledWith(
expect.stringContaining("GitLab")
);
expect(onSuggestionsClick).toHaveBeenCalledWith(
expect.stringContaining("merge request")
);
});
it("should use Bitbucket terminology when git_provider is bitbucket", async () => {
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
getConversationSpy.mockResolvedValue({
conversation_id: "test-bitbucket",
title: "Bitbucket Test",
selected_repository: "test-repo",
git_provider: "bitbucket",
selected_branch: "main",
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null,
});
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
bitbucket: "bitbucket-token",
},
});
const onSuggestionsClick = vi.fn();
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
const buttons = await screen.findAllByTestId("suggestion");
const prButton = buttons.find((button) =>
button.textContent?.includes("Push & Create PR"),
);
if (prButton) {
prButton.click();
}
// Should mention Bitbucket
expect(onSuggestionsClick).toHaveBeenCalledWith(
expect.stringContaining("Bitbucket")
);
});
});

View File

@@ -0,0 +1,256 @@
import userEvent from "@testing-library/user-event";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, afterEach, vi, it, expect } from "vitest";
import { ChatInput } from "#/components/features/chat/chat-input";
describe("ChatInput", () => {
const onSubmitMock = vi.fn();
afterEach(() => {
vi.clearAllMocks();
});
it("should render a textarea", () => {
render(<ChatInput onSubmit={onSubmitMock} />);
expect(screen.getByTestId("chat-input")).toBeInTheDocument();
expect(screen.getByRole("textbox")).toBeInTheDocument();
});
it("should call onSubmit when the user types and presses enter", async () => {
const user = userEvent.setup();
render(<ChatInput onSubmit={onSubmitMock} />);
const textarea = screen.getByRole("textbox");
await user.type(textarea, "Hello, world!");
await user.keyboard("{Enter}");
expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!");
});
it("should call onSubmit when pressing the submit button", async () => {
const user = userEvent.setup();
render(<ChatInput onSubmit={onSubmitMock} />);
const textarea = screen.getByRole("textbox");
const button = screen.getByRole("button");
await user.type(textarea, "Hello, world!");
await user.click(button);
expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!");
});
it("should not call onSubmit when the message is empty", async () => {
const user = userEvent.setup();
render(<ChatInput onSubmit={onSubmitMock} />);
const button = screen.getByRole("button");
await user.click(button);
expect(onSubmitMock).not.toHaveBeenCalled();
await user.keyboard("{Enter}");
expect(onSubmitMock).not.toHaveBeenCalled();
});
it("should not call onSubmit when the message is only whitespace", async () => {
const user = userEvent.setup();
render(<ChatInput onSubmit={onSubmitMock} />);
const textarea = screen.getByRole("textbox");
await user.type(textarea, " ");
await user.keyboard("{Enter}");
expect(onSubmitMock).not.toHaveBeenCalled();
await user.type(textarea, " \t\n");
await user.keyboard("{Enter}");
expect(onSubmitMock).not.toHaveBeenCalled();
});
it("should disable submit", async () => {
const user = userEvent.setup();
render(<ChatInput disabled onSubmit={onSubmitMock} />);
const button = screen.getByRole("button");
const textarea = screen.getByRole("textbox");
await user.type(textarea, "Hello, world!");
expect(button).toBeDisabled();
await user.click(button);
expect(onSubmitMock).not.toHaveBeenCalled();
await user.keyboard("{Enter}");
expect(onSubmitMock).not.toHaveBeenCalled();
});
it("should render a placeholder with translation key", () => {
render(<ChatInput onSubmit={onSubmitMock} />);
const textarea = screen.getByPlaceholderText("SUGGESTIONS$WHAT_TO_BUILD");
expect(textarea).toBeInTheDocument();
});
it("should create a newline instead of submitting when shift + enter is pressed", async () => {
const user = userEvent.setup();
render(<ChatInput onSubmit={onSubmitMock} />);
const textarea = screen.getByRole("textbox");
await user.type(textarea, "Hello, world!");
await user.keyboard("{Shift>} {Enter}"); // Shift + Enter
expect(onSubmitMock).not.toHaveBeenCalled();
// expect(textarea).toHaveValue("Hello, world!\n");
});
it("should clear the input message after sending a message", async () => {
const user = userEvent.setup();
render(<ChatInput onSubmit={onSubmitMock} />);
const textarea = screen.getByRole("textbox");
const button = screen.getByRole("button");
await user.type(textarea, "Hello, world!");
await user.keyboard("{Enter}");
expect(textarea).toHaveValue("");
await user.type(textarea, "Hello, world!");
await user.click(button);
expect(textarea).toHaveValue("");
});
it("should hide the submit button", () => {
render(<ChatInput onSubmit={onSubmitMock} showButton={false} />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("should call onChange when the user types", async () => {
const user = userEvent.setup();
const onChangeMock = vi.fn();
render(<ChatInput onSubmit={onSubmitMock} onChange={onChangeMock} />);
const textarea = screen.getByRole("textbox");
await user.type(textarea, "Hello, world!");
expect(onChangeMock).toHaveBeenCalledTimes("Hello, world!".length);
});
it("should have set the passed value", () => {
render(<ChatInput value="Hello, world!" onSubmit={onSubmitMock} />);
const textarea = screen.getByRole("textbox");
expect(textarea).toHaveValue("Hello, world!");
});
it("should display the stop button and trigger the callback", async () => {
const user = userEvent.setup();
const onStopMock = vi.fn();
render(
<ChatInput onSubmit={onSubmitMock} button="stop" onStop={onStopMock} />,
);
const stopButton = screen.getByTestId("stop-button");
await user.click(stopButton);
expect(onStopMock).toHaveBeenCalledOnce();
});
it("should call onFocus and onBlur when the textarea is focused and blurred", async () => {
const user = userEvent.setup();
const onFocusMock = vi.fn();
const onBlurMock = vi.fn();
render(
<ChatInput
onSubmit={onSubmitMock}
onFocus={onFocusMock}
onBlur={onBlurMock}
/>,
);
const textarea = screen.getByRole("textbox");
await user.click(textarea);
expect(onFocusMock).toHaveBeenCalledOnce();
await user.tab();
expect(onBlurMock).toHaveBeenCalledOnce();
});
it("should handle text paste correctly", () => {
const onSubmit = vi.fn();
const onChange = vi.fn();
render(<ChatInput onSubmit={onSubmit} onChange={onChange} />);
const input = screen.getByTestId("chat-input").querySelector("textarea");
expect(input).toBeTruthy();
// Fire paste event with text data
fireEvent.paste(input!, {
clipboardData: {
getData: (type: string) => (type === "text/plain" ? "test paste" : ""),
files: [],
},
});
});
it("should handle image paste correctly", () => {
const onSubmit = vi.fn();
const onFilesPaste = vi.fn();
render(<ChatInput onSubmit={onSubmit} onFilesPaste={onFilesPaste} />);
const input = screen.getByTestId("chat-input").querySelector("textarea");
expect(input).toBeTruthy();
// Create a paste event with an image file
const file = new File(["dummy content"], "image.png", {
type: "image/png",
});
// Fire paste event with image data
fireEvent.paste(input!, {
clipboardData: {
getData: () => "",
files: [file],
},
});
// Verify file paste was handled
expect(onFilesPaste).toHaveBeenCalledWith([file]);
});
it("should use the default maxRows value", () => {
// We can't directly test the maxRows prop as it's not exposed in the DOM
// Instead, we'll verify the component renders with the default props
render(<ChatInput onSubmit={onSubmitMock} />);
const textarea = screen.getByRole("textbox");
expect(textarea).toBeInTheDocument();
// The actual verification of maxRows=16 is handled internally by the TextareaAutosize component
// and affects how many rows the textarea can expand to
});
it("should not submit when Enter is pressed during IME composition", async () => {
const user = userEvent.setup();
render(<ChatInput onSubmit={onSubmitMock} />);
const textarea = screen.getByRole("textbox");
await user.type(textarea, "こんにちは");
// Simulate Enter during IME composition
fireEvent.keyDown(textarea, {
key: "Enter",
isComposing: true,
nativeEvent: { isComposing: true },
});
expect(onSubmitMock).not.toHaveBeenCalled();
// Simulate normal Enter after composition is done
fireEvent.keyDown(textarea, {
key: "Enter",
isComposing: false,
nativeEvent: { isComposing: false },
});
expect(onSubmitMock).toHaveBeenCalledWith("こんにちは");
});
});

View File

@@ -1,254 +1,16 @@
import {
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
test,
vi,
} from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderWithProviders } from "test-utils";
import type { Message } from "#/message";
import { SUGGESTIONS } from "#/utils/suggestions";
import { ChatInterface } from "#/components/features/chat/chat-interface";
import { useWsClient } from "#/context/ws-client-provider";
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
import { useWSErrorMessage } from "#/hooks/use-ws-error-message";
import { useConfig } from "#/hooks/query/use-config";
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
import { OpenHandsAction } from "#/types/core/actions";
// Mock the hooks
vi.mock("#/context/ws-client-provider");
vi.mock("#/hooks/use-optimistic-user-message");
vi.mock("#/hooks/use-ws-error-message");
vi.mock("#/hooks/query/use-config");
vi.mock("#/hooks/mutation/use-get-trajectory");
vi.mock("#/hooks/mutation/use-upload-files");
// Mock React Router hooks at the top level
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...actual,
useNavigate: () => vi.fn(),
useParams: () => ({ conversationId: "test-conversation-id" }),
useRouteLoaderData: vi.fn(() => ({})),
};
});
// Mock other hooks that might be used by the component
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => ({
providers: [],
}),
}));
vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
useConversationNameContextMenu: () => ({
isOpen: false,
contextMenuRef: { current: null },
handleContextMenu: vi.fn(),
handleClose: vi.fn(),
handleRename: vi.fn(),
handleDelete: vi.fn(),
}),
}));
vi.mock("react-redux", async () => {
const actual = await vi.importActual("react-redux");
return {
...actual,
useSelector: vi.fn((selector) => {
// Create a mock state object
const mockState = {
agent: {
curAgentState: "AWAITING_USER_INPUT",
},
initialQuery: {
selectedRepository: null,
replayJson: null,
},
conversation: {
messageToSend: null,
files: [],
images: [],
loadingFiles: [],
loadingImages: [],
},
status: {
curStatusMessage: null,
},
};
// Execute the selector function with our mock state
return selector(mockState);
}),
useDispatch: vi.fn(() => vi.fn()),
};
});
// Helper function to render with Router context
const renderChatInterfaceWithRouter = () =>
renderWithProviders(
<MemoryRouter>
<ChatInterface />
</MemoryRouter>,
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const renderChatInterface = (messages: Message[]) =>
renderWithProviders(
<MemoryRouter>
<ChatInterface />
</MemoryRouter>,
);
renderWithProviders(<ChatInterface />);
// Helper function to render with QueryClientProvider and Router (for newer tests)
const renderWithQueryClient = (
ui: React.ReactElement,
queryClient: QueryClient,
) =>
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
describe("ChatInterface - Chat Suggestions", () => {
// Create a new QueryClient for each test
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
// Default mock implementations
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
send: vi.fn(),
isLoadingMessages: false,
parsedEvents: [],
});
(
useOptimisticUserMessage as unknown as ReturnType<typeof vi.fn>
).mockReturnValue({
setOptimisticUserMessage: vi.fn(),
getOptimisticUserMessage: vi.fn(() => null),
});
(useWSErrorMessage as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
getErrorMessage: vi.fn(() => null),
setErrorMessage: vi.fn(),
removeErrorMessage: vi.fn(),
});
(useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: { APP_MODE: "local" },
});
(useGetTrajectory as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isLoading: false,
});
(useUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
mutateAsync: vi
.fn()
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
isLoading: false,
});
});
test("should show chat suggestions when there are no events", () => {
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
send: vi.fn(),
isLoadingMessages: false,
parsedEvents: [],
});
renderWithQueryClient(<ChatInterface />, queryClient);
// Check if ChatSuggestions is rendered
expect(screen.getByTestId("chat-suggestions")).toBeInTheDocument();
});
test("should show chat suggestions when there are only environment events", () => {
const environmentEvent: OpenHandsAction = {
id: 1,
source: "environment",
action: "system",
args: {
content: "source .openhands/setup.sh",
tools: null,
openhands_version: null,
agent_class: null,
},
message: "Running setup script",
timestamp: "2025-07-01T00:00:00Z",
};
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
send: vi.fn(),
isLoadingMessages: false,
parsedEvents: [environmentEvent],
});
renderWithQueryClient(<ChatInterface />, queryClient);
// Check if ChatSuggestions is still rendered with environment events
expect(screen.getByTestId("chat-suggestions")).toBeInTheDocument();
});
test("should hide chat suggestions when there is a user message", () => {
const userEvent: OpenHandsAction = {
id: 1,
source: "user",
action: "message",
args: {
content: "Hello",
image_urls: [],
file_urls: [],
},
message: "Hello",
timestamp: "2025-07-01T00:00:00Z",
};
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
send: vi.fn(),
isLoadingMessages: false,
parsedEvents: [userEvent],
});
renderWithQueryClient(<ChatInterface />, queryClient);
// Check if ChatSuggestions is not rendered with user events
expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument();
});
test("should hide chat suggestions when there is an optimistic user message", () => {
(
useOptimisticUserMessage as unknown as ReturnType<typeof vi.fn>
).mockReturnValue({
setOptimisticUserMessage: vi.fn(),
getOptimisticUserMessage: vi.fn(() => "Optimistic message"),
});
renderWithQueryClient(<ChatInterface />, queryClient);
// Check if ChatSuggestions is not rendered with optimistic user message
expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument();
});
});
describe("ChatInterface - Empty state", () => {
describe("Empty state", () => {
const { send: sendMock } = vi.hoisted(() => ({
send: vi.fn(),
}));
@@ -258,52 +20,21 @@ describe("ChatInterface - Empty state", () => {
send: sendMock,
status: "CONNECTED",
isLoadingMessages: false,
parsedEvents: [],
})),
}));
beforeAll(() => {
vi.mock("react-router", async (importActual) => ({
...(await importActual<typeof import("react-router")>()),
useRouteLoaderData: vi.fn(() => ({})),
}));
vi.mock("#/context/socket", async (importActual) => ({
...(await importActual<typeof import("#/context/ws-client-provider")>()),
useWsClient: useWsClientMock,
}));
});
beforeEach(() => {
// Reset mocks to ensure empty state
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
send: sendMock,
status: "CONNECTED",
isLoadingMessages: false,
parsedEvents: [],
});
(
useOptimisticUserMessage as unknown as ReturnType<typeof vi.fn>
).mockReturnValue({
setOptimisticUserMessage: vi.fn(),
getOptimisticUserMessage: vi.fn(() => null),
});
(useWSErrorMessage as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
getErrorMessage: vi.fn(() => null),
setErrorMessage: vi.fn(),
removeErrorMessage: vi.fn(),
});
(useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: { APP_MODE: "local" },
});
(useGetTrajectory as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isLoading: false,
});
(useUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
mutateAsync: vi
.fn()
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
isLoading: false,
});
});
afterEach(() => {
vi.clearAllMocks();
});
@@ -311,9 +42,9 @@ describe("ChatInterface - Empty state", () => {
it.todo("should render suggestions if empty");
it("should render the default suggestions", () => {
renderChatInterfaceWithRouter();
renderWithProviders(<ChatInterface />);
const suggestions = screen.getByTestId("chat-suggestions");
const suggestions = screen.getByTestId("suggestions");
const repoSuggestions = Object.keys(SUGGESTIONS.repo);
// check that there are at most 4 suggestions displayed
@@ -334,19 +65,18 @@ describe("ChatInterface - Empty state", () => {
send: sendMock,
status: "CONNECTED",
isLoadingMessages: false,
parsedEvents: [],
}));
const user = userEvent.setup();
renderChatInterfaceWithRouter();
renderWithProviders(<ChatInterface />);
const suggestions = screen.getByTestId("chat-suggestions");
const suggestions = screen.getByTestId("suggestions");
const displayedSuggestions = within(suggestions).getAllByRole("button");
const input = screen.getByTestId("chat-input");
await user.click(displayedSuggestions[0]);
// user message loaded to input
expect(screen.queryByTestId("chat-suggestions")).toBeInTheDocument();
expect(screen.queryByTestId("suggestions")).toBeInTheDocument();
expect(input).toHaveValue(displayedSuggestions[0].textContent);
},
);
@@ -358,12 +88,11 @@ describe("ChatInterface - Empty state", () => {
send: sendMock,
status: "CONNECTED",
isLoadingMessages: false,
parsedEvents: [],
}));
const user = userEvent.setup();
const { rerender } = renderChatInterfaceWithRouter();
const { rerender } = renderWithProviders(<ChatInterface />);
const suggestions = screen.getByTestId("chat-suggestions");
const suggestions = screen.getByTestId("suggestions");
const displayedSuggestions = within(suggestions).getAllByRole("button");
await user.click(displayedSuggestions[0]);
@@ -373,13 +102,8 @@ describe("ChatInterface - Empty state", () => {
send: sendMock,
status: "CONNECTED",
isLoadingMessages: false,
parsedEvents: [],
}));
rerender(
<MemoryRouter>
<ChatInterface />
</MemoryRouter>,
);
rerender(<ChatInterface />);
await waitFor(() =>
expect(sendMock).toHaveBeenCalledWith(expect.any(String)),
@@ -388,7 +112,7 @@ describe("ChatInterface - Empty state", () => {
);
});
describe.skip("ChatInterface - General functionality", () => {
describe.skip("ChatInterface", () => {
beforeAll(() => {
// mock useScrollToBottom hook
vi.mock("#/hooks/useScrollToBottom", () => ({
@@ -469,11 +193,7 @@ describe.skip("ChatInterface - General functionality", () => {
},
];
rerender(
<MemoryRouter>
<ChatInterface />
</MemoryRouter>,
);
rerender(<ChatInterface />);
const imageCarousel = screen.getByTestId("image-carousel");
expect(imageCarousel).toBeInTheDocument();
@@ -512,11 +232,7 @@ describe.skip("ChatInterface - General functionality", () => {
pending: true,
});
rerender(
<MemoryRouter>
<ChatInterface />
</MemoryRouter>,
);
rerender(<ChatInterface />);
expect(screen.getByTestId("continue-action-button")).toBeInTheDocument();
});
@@ -544,7 +260,10 @@ describe.skip("ChatInterface - General functionality", () => {
});
it("should render both GitHub buttons initially when ghToken is available", () => {
// Note: This test may need adjustment since useRouteLoaderData is now globally mocked
vi.mock("react-router", async (importActual) => ({
...(await importActual<typeof import("react-router")>()),
useRouteLoaderData: vi.fn(() => ({ ghToken: "test-token" })),
}));
const messages: Message[] = [
{
@@ -567,7 +286,10 @@ describe.skip("ChatInterface - General functionality", () => {
});
it("should render only 'Push changes to PR' button after PR is created", async () => {
// Note: This test may need adjustment since useRouteLoaderData is now globally mocked
vi.mock("react-router", async (importActual) => ({
...(await importActual<typeof import("react-router")>()),
useRouteLoaderData: vi.fn(() => ({ ghToken: "test-token" })),
}));
const messages: Message[] = [
{
@@ -586,11 +308,7 @@ describe.skip("ChatInterface - General functionality", () => {
await user.click(prButton);
// Re-render to trigger state update
rerender(
<MemoryRouter>
<ChatInterface />
</MemoryRouter>,
);
rerender(<ChatInterface />);
// Verify only one button is shown
const pushToPrButton = screen.getByRole("button", {
@@ -640,11 +358,7 @@ describe.skip("ChatInterface - General functionality", () => {
pending: true,
});
rerender(
<MemoryRouter>
<ChatInterface />
</MemoryRouter>,
);
rerender(<ChatInterface />);
expect(screen.getByTestId("feedback-actions")).toBeInTheDocument();
});

View File

@@ -3,7 +3,7 @@ import { screen } from "@testing-library/react";
import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { ExpandableMessage } from "#/components/features/chat/expandable-message";
import OptionService from "#/api/option-service/option-service.api";
import OpenHands from "#/api/open-hands";
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
@@ -113,7 +113,7 @@ describe("ExpandableMessage", () => {
});
it("should render the out of credits message when the user is out of credits", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - We only care about the APP_MODE and FEATURE_FLAGS fields
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",

View File

@@ -2,8 +2,6 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, test, vi } from "vitest";
import { AccountSettingsContextMenu } from "#/components/features/context-menu/account-settings-context-menu";
import { MemoryRouter } from "react-router";
import { renderWithProviders } from "../../../test-utils";
describe("AccountSettingsContextMenu", () => {
const user = userEvent.setup();
@@ -11,11 +9,6 @@ describe("AccountSettingsContextMenu", () => {
const onLogoutMock = vi.fn();
const onCloseMock = vi.fn();
// Create a wrapper with MemoryRouter and renderWithProviders
const renderWithRouter = (ui: React.ReactElement) => {
return renderWithProviders(<MemoryRouter>{ui}</MemoryRouter>);
};
afterEach(() => {
onClickAccountSettingsMock.mockClear();
onLogoutMock.mockClear();
@@ -23,7 +16,7 @@ describe("AccountSettingsContextMenu", () => {
});
it("should always render the right options", () => {
renderWithRouter(
render(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
@@ -37,7 +30,7 @@ describe("AccountSettingsContextMenu", () => {
});
it("should call onLogout when the logout option is clicked", async () => {
renderWithRouter(
render(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
@@ -51,7 +44,7 @@ describe("AccountSettingsContextMenu", () => {
});
test("logout button is always enabled", async () => {
renderWithRouter(
render(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
@@ -65,7 +58,7 @@ describe("AccountSettingsContextMenu", () => {
});
it("should call onClose when clicking outside of the element", async () => {
renderWithRouter(
render(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}

View File

@@ -3,13 +3,13 @@ import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
import SettingsService from "#/settings-service/settings-service.api";
import OpenHands from "#/api/open-hands";
describe("AnalyticsConsentFormModal", () => {
it("should call saveUserSettings with consent", async () => {
const user = userEvent.setup();
const onCloseMock = vi.fn();
const saveUserSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const saveUserSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
render(<AnalyticsConsentFormModal onClose={onCloseMock} />, {
wrapper: ({ children }) => (

View File

@@ -8,7 +8,7 @@ import {
UserMessageAction,
} from "#/types/core/actions";
import { OpenHandsObservation } from "#/types/core/observations";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import OpenHands from "#/api/open-hands";
import { Conversation } from "#/api/open-hands.types";
vi.mock("react-router", () => ({
@@ -80,7 +80,7 @@ describe("Messages", () => {
});
it("should render a launch to microagent action button on chat messages only if it is a user message", () => {
const getConversationSpy = vi.spyOn(ConversationService, "getConversation");
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
const mockConversation: Conversation = {
conversation_id: "123",
title: "Test Conversation",

View File

@@ -12,7 +12,7 @@ import {
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { formatTimeDelta } from "#/utils/format-time-delta";
import { ConversationCard } from "#/components/features/conversation-panel/conversation-card/conversation-card";
import { ConversationCard } from "#/components/features/conversation-panel/conversation-card";
import { clickOnEditButton } from "./utils";
// We'll use the actual i18next implementation but override the translation function
@@ -64,6 +64,7 @@ describe("ConversationCard", () => {
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
@@ -75,6 +76,7 @@ describe("ConversationCard", () => {
within(card).getByText("Conversation 1");
// Just check that the card contains the expected text content
expect(card).toHaveTextContent("Created");
expect(card).toHaveTextContent("ago");
// Use a regex to match the time part since it might have whitespace
@@ -89,6 +91,7 @@ describe("ConversationCard", () => {
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
@@ -103,6 +106,7 @@ describe("ConversationCard", () => {
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
isActive
title="Conversation 1"
selectedRepository={{
selected_repository: "org/selectedRepository",
@@ -123,6 +127,7 @@ describe("ConversationCard", () => {
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
@@ -131,14 +136,7 @@ describe("ConversationCard", () => {
/>,
);
// Context menu is always in the DOM but hidden by CSS classes when contextMenuOpen is false
const contextMenu = screen.queryByTestId("context-menu");
if (contextMenu) {
const contextMenuParent = contextMenu.parentElement;
if (contextMenuParent) {
expect(contextMenuParent).toHaveClass("opacity-0", "invisible");
}
}
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
@@ -150,6 +148,7 @@ describe("ConversationCard", () => {
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
@@ -171,6 +170,7 @@ describe("ConversationCard", () => {
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
@@ -194,6 +194,7 @@ describe("ConversationCard", () => {
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={{
@@ -222,6 +223,7 @@ describe("ConversationCard", () => {
const { rerender } = renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
@@ -237,6 +239,7 @@ describe("ConversationCard", () => {
rerender(
<ConversationCard
onDelete={onDelete}
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
@@ -249,14 +252,7 @@ describe("ConversationCard", () => {
const title = screen.getByTestId("conversation-card-title");
expect(title).toBeEnabled();
// Context menu should be hidden after edit button is clicked (check CSS classes on parent div)
const contextMenu = screen.queryByTestId("context-menu");
if (contextMenu) {
const contextMenuParent = contextMenu.parentElement;
if (contextMenuParent) {
expect(contextMenuParent).toHaveClass("opacity-0", "invisible");
}
}
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
// expect to be focused
expect(document.activeElement).toBe(title);
@@ -265,14 +261,16 @@ describe("ConversationCard", () => {
await user.tab();
expect(onChangeTitle).toHaveBeenCalledWith("New Conversation Name");
expect(title).toHaveValue("New Conversation Name");
});
it("should not call onChange title", async () => {
it("should reset title and not call onChangeTitle when the title is empty", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
@@ -289,7 +287,8 @@ describe("ConversationCard", () => {
await user.clear(title);
await user.tab();
expect(onChangeTitle).not.toBeCalled();
expect(onChangeTitle).not.toHaveBeenCalled();
expect(title).toHaveValue("Conversation 1");
});
test("clicking the title should trigger the onClick handler", async () => {
@@ -298,6 +297,7 @@ describe("ConversationCard", () => {
<ConversationCard
onClick={onClick}
onDelete={onDelete}
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
@@ -317,6 +317,7 @@ describe("ConversationCard", () => {
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
@@ -340,6 +341,7 @@ describe("ConversationCard", () => {
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
@@ -363,6 +365,7 @@ describe("ConversationCard", () => {
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
@@ -382,6 +385,7 @@ describe("ConversationCard", () => {
onDelete={onDelete}
onChangeTitle={onChangeTitle}
showOptions
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
@@ -401,6 +405,7 @@ describe("ConversationCard", () => {
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
@@ -494,4 +499,38 @@ describe("ConversationCard", () => {
expect(screen.queryByTestId("ellipsis-button")).not.toBeInTheDocument();
});
describe("state indicator", () => {
it("should render the 'STOPPED' indicator by default", () => {
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
screen.getByTestId("STOPPED-indicator");
});
it("should render the other indicators when provided", () => {
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
conversationStatus="RUNNING"
/>,
);
expect(screen.queryByTestId("STOPPED-indicator")).not.toBeInTheDocument();
screen.getByTestId("RUNNING-indicator");
});
});
});

View File

@@ -6,7 +6,7 @@ import { createRoutesStub } from "react-router";
import React from "react";
import { renderWithProviders } from "test-utils";
import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import OpenHands from "#/api/open-hands";
import { Conversation } from "#/api/open-hands.types";
describe("ConversationPanel", () => {
@@ -85,7 +85,7 @@ describe("ConversationPanel", () => {
vi.clearAllMocks();
vi.restoreAllMocks();
// Setup default mock for getUserConversations
vi.spyOn(ConversationService, "getUserConversations").mockResolvedValue({
vi.spyOn(OpenHands, "getUserConversations").mockResolvedValue({
results: [...mockConversations],
next_page_id: null,
});
@@ -101,10 +101,7 @@ describe("ConversationPanel", () => {
});
it("should display an empty state when there are no conversations", async () => {
const getUserConversationsSpy = vi.spyOn(
ConversationService,
"getUserConversations",
);
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockResolvedValue({
results: [],
next_page_id: null,
@@ -117,10 +114,7 @@ describe("ConversationPanel", () => {
});
it("should handle an error when fetching conversations", async () => {
const getUserConversationsSpy = vi.spyOn(
ConversationService,
"getUserConversations",
);
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockRejectedValue(
new Error("Failed to fetch conversations"),
);
@@ -136,18 +130,13 @@ describe("ConversationPanel", () => {
renderConversationPanel();
let cards = await screen.findAllByTestId("conversation-card");
// Delete button should not be visible initially (context menu is closed)
// The context menu is always in the DOM but hidden by CSS classes on the parent div
const contextMenuParent = within(cards[0]).queryByTestId(
"context-menu",
)?.parentElement;
if (contextMenuParent) {
expect(contextMenuParent).toHaveClass("opacity-0", "invisible");
}
expect(
within(cards[0]).queryByTestId("delete-button"),
).not.toBeInTheDocument();
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const deleteButton = within(cards[0]).getByTestId("delete-button");
const deleteButton = screen.getByTestId("delete-button");
// Click the first delete button
await user.click(deleteButton);
@@ -209,17 +198,14 @@ describe("ConversationPanel", () => {
},
];
const getUserConversationsSpy = vi.spyOn(
ConversationService,
"getUserConversations",
);
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockImplementation(async () => ({
results: mockData,
next_page_id: null,
}));
const deleteUserConversationSpy = vi.spyOn(
ConversationService,
OpenHands,
"deleteUserConversation",
);
deleteUserConversationSpy.mockImplementation(async (id: string) => {
@@ -236,7 +222,7 @@ describe("ConversationPanel", () => {
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const deleteButton = within(cards[0]).getByTestId("delete-button");
const deleteButton = screen.getByTestId("delete-button");
// Click the first delete button
await user.click(deleteButton);
@@ -269,10 +255,7 @@ describe("ConversationPanel", () => {
it("should refetch data on rerenders", async () => {
const user = userEvent.setup();
const getUserConversationsSpy = vi.spyOn(
ConversationService,
"getUserConversations",
);
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockResolvedValue({
results: [...mockConversations],
next_page_id: null,
@@ -369,10 +352,7 @@ describe("ConversationPanel", () => {
},
];
const getUserConversationsSpy = vi.spyOn(
ConversationService,
"getUserConversations",
);
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockResolvedValue({
results: mockRunningConversations,
next_page_id: null,
@@ -388,7 +368,7 @@ describe("ConversationPanel", () => {
await user.click(ellipsisButton);
// Stop button should be available for RUNNING conversation
const stopButton = within(cards[0]).getByTestId("stop-button");
const stopButton = screen.getByTestId("stop-button");
expect(stopButton).toBeInTheDocument();
// Click the stop button
@@ -439,19 +419,13 @@ describe("ConversationPanel", () => {
},
];
const getUserConversationsSpy = vi.spyOn(
ConversationService,
"getUserConversations",
);
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockImplementation(async () => ({
results: mockData,
next_page_id: null,
}));
const stopConversationSpy = vi.spyOn(
ConversationService,
"stopConversation",
);
const stopConversationSpy = vi.spyOn(OpenHands, "stopConversation");
stopConversationSpy.mockImplementation(async (id: string) => {
const conversation = mockData.find((conv) => conv.conversation_id === id);
if (conversation) {
@@ -470,7 +444,7 @@ describe("ConversationPanel", () => {
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const stopButton = within(cards[0]).getByTestId("stop-button");
const stopButton = screen.getByTestId("stop-button");
// Click the stop button
await user.click(stopButton);
@@ -533,10 +507,7 @@ describe("ConversationPanel", () => {
},
];
const getUserConversationsSpy = vi.spyOn(
ConversationService,
"getUserConversations",
);
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockResolvedValue({
results: mockMixedStatusConversations,
next_page_id: null,
@@ -553,51 +524,29 @@ describe("ConversationPanel", () => {
);
await user.click(runningEllipsisButton);
expect(within(cards[0]).getByTestId("stop-button")).toBeInTheDocument();
expect(screen.getByTestId("stop-button")).toBeInTheDocument();
// Click outside to close the menu
await user.click(document.body);
// Wait for context menu to close (check CSS classes on parent div)
await waitFor(() => {
const contextMenuParent = within(cards[0]).queryByTestId(
"context-menu",
)?.parentElement;
if (contextMenuParent) {
expect(contextMenuParent).toHaveClass("opacity-0", "invisible");
}
});
// Test STARTING conversation - should show stop button
const startingEllipsisButton = within(cards[1]).getByTestId(
"ellipsis-button",
);
await user.click(startingEllipsisButton);
expect(within(cards[1]).getByTestId("stop-button")).toBeInTheDocument();
expect(screen.getByTestId("stop-button")).toBeInTheDocument();
// Click outside to close the menu
await user.click(document.body);
// Wait for context menu to close (check CSS classes on parent div)
await waitFor(() => {
const contextMenuParent = within(cards[1]).queryByTestId(
"context-menu",
)?.parentElement;
if (contextMenuParent) {
expect(contextMenuParent).toHaveClass("opacity-0", "invisible");
}
});
// Test STOPPED conversation - should NOT show stop button
const stoppedEllipsisButton = within(cards[2]).getByTestId(
"ellipsis-button",
);
await user.click(stoppedEllipsisButton);
expect(
within(cards[2]).queryByTestId("stop-button"),
).not.toBeInTheDocument();
expect(screen.queryByTestId("stop-button")).not.toBeInTheDocument();
});
it("should show edit button in context menu", async () => {
@@ -611,10 +560,10 @@ describe("ConversationPanel", () => {
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Edit button should be visible within the first card's context menu
const editButton = within(cards[0]).getByTestId("edit-button");
// Edit button should be visible
const editButton = screen.getByTestId("edit-button");
expect(editButton).toBeInTheDocument();
expect(editButton).toHaveTextContent("BUTTON$RENAME");
expect(editButton).toHaveTextContent("BUTTON$EDIT_TITLE");
});
it("should enter edit mode when edit button is clicked", async () => {
@@ -627,8 +576,8 @@ describe("ConversationPanel", () => {
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Click edit button within the first card's context menu
const editButton = within(cards[0]).getByTestId("edit-button");
// Click edit button
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Should find input field instead of title text
@@ -643,10 +592,7 @@ describe("ConversationPanel", () => {
const user = userEvent.setup();
// Mock the updateConversation API call
const updateConversationSpy = vi.spyOn(
ConversationService,
"updateConversation",
);
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
// Mock the toast function
@@ -663,7 +609,7 @@ describe("ConversationPanel", () => {
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = within(cards[0]).getByTestId("edit-button");
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title
@@ -683,10 +629,7 @@ describe("ConversationPanel", () => {
it("should save title when Enter key is pressed", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(
ConversationService,
"updateConversation",
);
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
@@ -697,7 +640,7 @@ describe("ConversationPanel", () => {
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = within(cards[0]).getByTestId("edit-button");
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title and press Enter
@@ -715,10 +658,7 @@ describe("ConversationPanel", () => {
it("should trim whitespace from title", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(
ConversationService,
"updateConversation",
);
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
@@ -729,7 +669,7 @@ describe("ConversationPanel", () => {
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = within(cards[0]).getByTestId("edit-button");
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title with extra whitespace
@@ -742,15 +682,15 @@ describe("ConversationPanel", () => {
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Trimmed Title",
});
// Verify input shows trimmed value
expect(titleInput).toHaveValue("Trimmed Title");
});
it("should revert to original title when empty", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(
ConversationService,
"updateConversation",
);
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
@@ -761,7 +701,7 @@ describe("ConversationPanel", () => {
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = within(cards[0]).getByTestId("edit-button");
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Clear the title completely
@@ -771,15 +711,15 @@ describe("ConversationPanel", () => {
// Verify API was not called
expect(updateConversationSpy).not.toHaveBeenCalled();
// Verify input reverted to original value
expect(titleInput).toHaveValue("Conversation 1");
});
it("should handle API error when updating title", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(
ConversationService,
"updateConversation",
);
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockRejectedValue(new Error("API Error"));
vi.mock("#/utils/custom-toast-handlers", () => ({
@@ -794,7 +734,7 @@ describe("ConversationPanel", () => {
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = within(cards[0]).getByTestId("edit-button");
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title
@@ -824,32 +764,22 @@ describe("ConversationPanel", () => {
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Verify context menu is open within the first card
const contextMenu = within(cards[0]).getByTestId("context-menu");
// Verify context menu is open
const contextMenu = screen.getByTestId("context-menu");
expect(contextMenu).toBeInTheDocument();
// Click edit button within the first card's context menu
const editButton = within(cards[0]).getByTestId("edit-button");
// Click edit button
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Wait for context menu to close after edit button click (check CSS classes on parent div)
await waitFor(() => {
const contextMenuParent = within(cards[0]).queryByTestId(
"context-menu",
)?.parentElement;
if (contextMenuParent) {
expect(contextMenuParent).toHaveClass("opacity-0", "invisible");
}
});
// Verify context menu is closed
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
});
it("should not call API when title is unchanged", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(
ConversationService,
"updateConversation",
);
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
@@ -860,14 +790,15 @@ describe("ConversationPanel", () => {
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = within(cards[0]).getByTestId("edit-button");
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Don't change the title, just blur
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.tab();
// Verify API was NOT called with the same title (since handleConversationTitleChange will always be called)
expect(updateConversationSpy).not.toHaveBeenCalledWith("1", {
// Verify API was called with the same title (since handleConversationTitleChange will always be called)
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Conversation 1",
});
});
@@ -875,10 +806,7 @@ describe("ConversationPanel", () => {
it("should handle special characters in title", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(
ConversationService,
"updateConversation",
);
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
@@ -889,7 +817,7 @@ describe("ConversationPanel", () => {
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = within(cards[0]).getByTestId("edit-button");
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title with special characters

View File

@@ -1,573 +0,0 @@
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { ConversationName } from "#/components/features/conversation/conversation-name";
import { ConversationNameContextMenu } from "#/components/features/conversation/conversation-name-context-menu";
import { BrowserRouter } from "react-router";
// Mock the hooks and utilities
const mockMutate = vi.fn();
vi.mock("#/hooks/query/use-active-conversation", () => ({
useActiveConversation: () => ({
data: {
conversation_id: "test-conversation-id",
title: "Test Conversation",
status: "RUNNING",
},
}),
}));
vi.mock("#/hooks/mutation/use-update-conversation", () => ({
useUpdateConversation: () => ({
mutate: mockMutate,
}),
}));
vi.mock("#/utils/custom-toast-handlers", () => ({
displaySuccessToast: vi.fn(),
}));
// Mock react-i18next
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
CONVERSATION$TITLE_UPDATED: "Conversation title updated",
BUTTON$RENAME: "Rename",
BUTTON$EXPORT_CONVERSATION: "Export Conversation",
BUTTON$DOWNLOAD_VIA_VSCODE: "Download via VS Code",
BUTTON$SHOW_AGENT_TOOLS_AND_METADATA: "Show Agent Tools",
CONVERSATION$SHOW_MICROAGENTS: "Show Microagents",
BUTTON$DISPLAY_COST: "Display Cost",
COMMON$CLOSE_CONVERSATION_STOP_RUNTIME:
"Close Conversation (Stop Runtime)",
COMMON$DELETE_CONVERSATION: "Delete Conversation",
};
return translations[key] || key;
},
i18n: {
changeLanguage: () => new Promise(() => {}),
},
}),
};
});
// Helper function to render ConversationName with Router context
const renderConversationNameWithRouter = () => {
return renderWithProviders(
<BrowserRouter>
<ConversationName />
</BrowserRouter>,
);
};
describe("ConversationName", () => {
beforeAll(() => {
vi.stubGlobal("window", {
open: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
});
afterEach(() => {
vi.clearAllMocks();
});
it("should render the conversation name in view mode", () => {
renderConversationNameWithRouter();
const container = screen.getByTestId("conversation-name");
const titleElement = within(container).getByTestId(
"conversation-name-title",
);
expect(container).toBeInTheDocument();
expect(titleElement).toBeInTheDocument();
expect(titleElement).toHaveTextContent("Test Conversation");
});
it("should switch to edit mode on double click", async () => {
const user = userEvent.setup();
renderConversationNameWithRouter();
const titleElement = screen.getByTestId("conversation-name-title");
// Initially should be in view mode
expect(titleElement).toBeInTheDocument();
expect(
screen.queryByTestId("conversation-name-input"),
).not.toBeInTheDocument();
// Double click to enter edit mode
await user.dblClick(titleElement);
// Should now be in edit mode
expect(
screen.queryByTestId("conversation-name-title"),
).not.toBeInTheDocument();
const inputElement = screen.getByTestId("conversation-name-input");
expect(inputElement).toBeInTheDocument();
expect(inputElement).toHaveValue("Test Conversation");
});
it("should update conversation title when input loses focus with valid value", async () => {
const user = userEvent.setup();
renderConversationNameWithRouter();
const titleElement = screen.getByTestId("conversation-name-title");
await user.dblClick(titleElement);
const inputElement = screen.getByTestId("conversation-name-input");
await user.clear(inputElement);
await user.type(inputElement, "New Conversation Title");
await user.tab(); // Trigger blur event
// Verify that the update function was called
expect(mockMutate).toHaveBeenCalledWith(
{
conversationId: "test-conversation-id",
newTitle: "New Conversation Title",
},
expect.any(Object),
);
});
it("should not update conversation when title is unchanged", async () => {
const user = userEvent.setup();
renderConversationNameWithRouter();
const titleElement = screen.getByTestId("conversation-name-title");
await user.dblClick(titleElement);
const inputElement = screen.getByTestId("conversation-name-input");
// Keep the same title
await user.tab();
// Should still have the original title
expect(inputElement).toHaveValue("Test Conversation");
});
it("should not call the API if user attempts to save an unchanged title", async () => {
const user = userEvent.setup();
renderConversationNameWithRouter();
const titleElement = screen.getByTestId("conversation-name-title");
await user.dblClick(titleElement);
const inputElement = screen.getByTestId("conversation-name-input");
// Verify the input has the original title
expect(inputElement).toHaveValue("Test Conversation");
// Trigger blur without changing the title
await user.tab();
// Verify that the API was NOT called
expect(mockMutate).not.toHaveBeenCalled();
});
it("should reset input value when title is empty and blur", async () => {
const user = userEvent.setup();
renderConversationNameWithRouter();
const titleElement = screen.getByTestId("conversation-name-title");
await user.dblClick(titleElement);
const inputElement = screen.getByTestId("conversation-name-input");
await user.clear(inputElement);
await user.tab();
// Should reset to original title
expect(inputElement).toHaveValue("Test Conversation");
});
it("should trim whitespace from input value", async () => {
const user = userEvent.setup();
renderConversationNameWithRouter();
const titleElement = screen.getByTestId("conversation-name-title");
await user.dblClick(titleElement);
const inputElement = screen.getByTestId("conversation-name-input");
await user.clear(inputElement);
await user.type(inputElement, " Trimmed Title ");
await user.tab();
// Should call mutation with trimmed value
expect(mockMutate).toHaveBeenCalledWith(
{
conversationId: "test-conversation-id",
newTitle: "Trimmed Title",
},
expect.any(Object),
);
});
it("should handle Enter key to save changes", async () => {
const user = userEvent.setup();
renderConversationNameWithRouter();
const titleElement = screen.getByTestId("conversation-name-title");
await user.dblClick(titleElement);
const inputElement = screen.getByTestId("conversation-name-input");
await user.clear(inputElement);
await user.type(inputElement, "New Title");
await user.keyboard("{Enter}");
// Should have the new title
expect(inputElement).toHaveValue("New Title");
});
it("should prevent event propagation when clicking input in edit mode", async () => {
const user = userEvent.setup();
renderConversationNameWithRouter();
const titleElement = screen.getByTestId("conversation-name-title");
await user.dblClick(titleElement);
const inputElement = screen.getByTestId("conversation-name-input");
const clickEvent = new MouseEvent("click", { bubbles: true });
const preventDefaultSpy = vi.spyOn(clickEvent, "preventDefault");
const stopPropagationSpy = vi.spyOn(clickEvent, "stopPropagation");
inputElement.dispatchEvent(clickEvent);
expect(preventDefaultSpy).toHaveBeenCalled();
expect(stopPropagationSpy).toHaveBeenCalled();
});
it("should return to view mode after blur", async () => {
const user = userEvent.setup();
renderConversationNameWithRouter();
const titleElement = screen.getByTestId("conversation-name-title");
await user.dblClick(titleElement);
// Should be in edit mode
expect(screen.getByTestId("conversation-name-input")).toBeInTheDocument();
await user.tab();
// Should be back in view mode
expect(screen.getByTestId("conversation-name-title")).toBeInTheDocument();
expect(
screen.queryByTestId("conversation-name-input"),
).not.toBeInTheDocument();
});
it("should focus input when entering edit mode", async () => {
const user = userEvent.setup();
renderConversationNameWithRouter();
const titleElement = screen.getByTestId("conversation-name-title");
await user.dblClick(titleElement);
const inputElement = screen.getByTestId("conversation-name-input");
expect(inputElement).toHaveFocus();
});
});
describe("ConversationNameContextMenu", () => {
const defaultProps = {
onClose: vi.fn(),
};
afterEach(() => {
vi.clearAllMocks();
});
it("should render all menu options when all handlers are provided", () => {
const handlers = {
onRename: vi.fn(),
onDelete: vi.fn(),
onStop: vi.fn(),
onDisplayCost: vi.fn(),
onShowAgentTools: vi.fn(),
onShowMicroagents: vi.fn(),
onExportConversation: vi.fn(),
onDownloadViaVSCode: vi.fn(),
};
renderWithProviders(
<ConversationNameContextMenu {...defaultProps} {...handlers} />,
);
expect(screen.getByTestId("rename-button")).toBeInTheDocument();
expect(screen.getByTestId("delete-button")).toBeInTheDocument();
expect(screen.getByTestId("stop-button")).toBeInTheDocument();
expect(screen.getByTestId("display-cost-button")).toBeInTheDocument();
expect(screen.getByTestId("show-agent-tools-button")).toBeInTheDocument();
expect(screen.getByTestId("show-microagents-button")).toBeInTheDocument();
expect(
screen.getByTestId("export-conversation-button"),
).toBeInTheDocument();
expect(screen.getByTestId("download-vscode-button")).toBeInTheDocument();
});
it("should not render menu options when handlers are not provided", () => {
renderWithProviders(<ConversationNameContextMenu {...defaultProps} />);
expect(screen.queryByTestId("rename-button")).not.toBeInTheDocument();
expect(screen.queryByTestId("delete-button")).not.toBeInTheDocument();
expect(screen.queryByTestId("stop-button")).not.toBeInTheDocument();
expect(screen.queryByTestId("display-cost-button")).not.toBeInTheDocument();
expect(
screen.queryByTestId("show-agent-tools-button"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("show-microagents-button"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("export-conversation-button"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("download-vscode-button"),
).not.toBeInTheDocument();
});
it("should call rename handler when rename button is clicked", async () => {
const user = userEvent.setup();
const onRename = vi.fn();
renderWithProviders(
<ConversationNameContextMenu {...defaultProps} onRename={onRename} />,
);
const renameButton = screen.getByTestId("rename-button");
await user.click(renameButton);
expect(onRename).toHaveBeenCalledTimes(1);
});
it("should call delete handler when delete button is clicked", async () => {
const user = userEvent.setup();
const onDelete = vi.fn();
renderWithProviders(
<ConversationNameContextMenu {...defaultProps} onDelete={onDelete} />,
);
const deleteButton = screen.getByTestId("delete-button");
await user.click(deleteButton);
expect(onDelete).toHaveBeenCalledTimes(1);
});
it("should call stop handler when stop button is clicked", async () => {
const user = userEvent.setup();
const onStop = vi.fn();
renderWithProviders(
<ConversationNameContextMenu {...defaultProps} onStop={onStop} />,
);
const stopButton = screen.getByTestId("stop-button");
await user.click(stopButton);
expect(onStop).toHaveBeenCalledTimes(1);
});
it("should call display cost handler when display cost button is clicked", async () => {
const user = userEvent.setup();
const onDisplayCost = vi.fn();
renderWithProviders(
<ConversationNameContextMenu
{...defaultProps}
onDisplayCost={onDisplayCost}
/>,
);
const displayCostButton = screen.getByTestId("display-cost-button");
await user.click(displayCostButton);
expect(onDisplayCost).toHaveBeenCalledTimes(1);
});
it("should call show agent tools handler when show agent tools button is clicked", async () => {
const user = userEvent.setup();
const onShowAgentTools = vi.fn();
renderWithProviders(
<ConversationNameContextMenu
{...defaultProps}
onShowAgentTools={onShowAgentTools}
/>,
);
const showAgentToolsButton = screen.getByTestId("show-agent-tools-button");
await user.click(showAgentToolsButton);
expect(onShowAgentTools).toHaveBeenCalledTimes(1);
});
it("should call show microagents handler when show microagents button is clicked", async () => {
const user = userEvent.setup();
const onShowMicroagents = vi.fn();
renderWithProviders(
<ConversationNameContextMenu
{...defaultProps}
onShowMicroagents={onShowMicroagents}
/>,
);
const showMicroagentsButton = screen.getByTestId("show-microagents-button");
await user.click(showMicroagentsButton);
expect(onShowMicroagents).toHaveBeenCalledTimes(1);
});
it("should call export conversation handler when export conversation button is clicked", async () => {
const user = userEvent.setup();
const onExportConversation = vi.fn();
renderWithProviders(
<ConversationNameContextMenu
{...defaultProps}
onExportConversation={onExportConversation}
/>,
);
const exportButton = screen.getByTestId("export-conversation-button");
await user.click(exportButton);
expect(onExportConversation).toHaveBeenCalledTimes(1);
});
it("should call download via VSCode handler when download via VSCode button is clicked", async () => {
const user = userEvent.setup();
const onDownloadViaVSCode = vi.fn();
renderWithProviders(
<ConversationNameContextMenu
{...defaultProps}
onDownloadViaVSCode={onDownloadViaVSCode}
/>,
);
const downloadButton = screen.getByTestId("download-vscode-button");
await user.click(downloadButton);
expect(onDownloadViaVSCode).toHaveBeenCalledTimes(1);
});
it("should render separators between logical groups", () => {
const handlers = {
onRename: vi.fn(),
onShowAgentTools: vi.fn(),
onExportConversation: vi.fn(),
onDisplayCost: vi.fn(),
onStop: vi.fn(),
};
renderWithProviders(
<ConversationNameContextMenu {...defaultProps} {...handlers} />,
);
// Look for separator elements using test IDs
expect(screen.getByTestId("separator-tools")).toBeInTheDocument();
expect(screen.getByTestId("separator-export")).toBeInTheDocument();
expect(screen.getByTestId("separator-info-control")).toBeInTheDocument();
});
it("should apply correct positioning class when position is top", () => {
const handlers = {
onRename: vi.fn(),
};
renderWithProviders(
<ConversationNameContextMenu
{...defaultProps}
{...handlers}
position="top"
/>,
);
const contextMenu = screen.getByTestId("conversation-name-context-menu");
expect(contextMenu).toHaveClass("bottom-full");
});
it("should apply correct positioning class when position is bottom", () => {
const handlers = {
onRename: vi.fn(),
};
renderWithProviders(
<ConversationNameContextMenu
{...defaultProps}
{...handlers}
position="bottom"
/>,
);
const contextMenu = screen.getByTestId("conversation-name-context-menu");
expect(contextMenu).toHaveClass("top-full");
});
it("should render correct text content for each menu option", () => {
const handlers = {
onRename: vi.fn(),
onDelete: vi.fn(),
onStop: vi.fn(),
onDisplayCost: vi.fn(),
onShowAgentTools: vi.fn(),
onShowMicroagents: vi.fn(),
onExportConversation: vi.fn(),
onDownloadViaVSCode: vi.fn(),
};
renderWithProviders(
<ConversationNameContextMenu {...defaultProps} {...handlers} />,
);
expect(screen.getByTestId("rename-button")).toHaveTextContent("Rename");
expect(screen.getByTestId("delete-button")).toHaveTextContent(
"Delete Conversation",
);
expect(screen.getByTestId("stop-button")).toHaveTextContent(
"Close Conversation (Stop Runtime)",
);
expect(screen.getByTestId("display-cost-button")).toHaveTextContent(
"Display Cost",
);
expect(screen.getByTestId("show-agent-tools-button")).toHaveTextContent(
"Show Agent Tools",
);
expect(screen.getByTestId("show-microagents-button")).toHaveTextContent(
"Show Microagents",
);
expect(screen.getByTestId("export-conversation-button")).toHaveTextContent(
"Export Conversation",
);
expect(screen.getByTestId("download-vscode-button")).toHaveTextContent(
"Download via VS Code",
);
});
it("should call onClose when context menu is closed", () => {
const onClose = vi.fn();
const handlers = {
onRename: vi.fn(),
};
renderWithProviders(
<ConversationNameContextMenu
{...defaultProps}
onClose={onClose}
{...handlers}
/>,
);
// The onClose is typically called by the parent component when clicking outside
// This test verifies the prop is properly passed
expect(onClose).toBeDefined();
});
});

View File

@@ -1,389 +0,0 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { ServerStatus } from "#/components/features/controls/server-status";
import { ServerStatusContextMenu } from "#/components/features/controls/server-status-context-menu";
import { ConversationStatus } from "#/types/conversation-status";
import { AgentState } from "#/types/agent-state";
// Mock the conversation slice actions
vi.mock("#/state/conversation-slice", () => ({
setShouldStopConversation: vi.fn(),
setShouldStartConversation: vi.fn(),
default: {
name: "conversation",
initialState: {
isRightPanelShown: true,
shouldStopConversation: false,
shouldStartConversation: false,
},
reducers: {},
},
}));
// Mock react-redux
vi.mock("react-redux", () => ({
useSelector: vi.fn((selector) => {
// Mock the selector to return different agent states based on test needs
return {
curAgentState: AgentState.RUNNING,
};
}),
Provider: ({ children }: { children: React.ReactNode }) => children,
}));
// Mock the custom hooks
const mockStartConversationMutate = vi.fn();
const mockStopConversationMutate = vi.fn();
vi.mock("#/hooks/mutation/use-start-conversation", () => ({
useStartConversation: () => ({
mutate: mockStartConversationMutate,
}),
}));
vi.mock("#/hooks/mutation/use-stop-conversation", () => ({
useStopConversation: () => ({
mutate: mockStopConversationMutate,
}),
}));
vi.mock("#/hooks/use-conversation-id", () => ({
useConversationId: () => ({
conversationId: "test-conversation-id",
}),
}));
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => ({
providers: [],
}),
}));
// Mock react-i18next
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
COMMON$RUNNING: "Running",
COMMON$SERVER_STOPPED: "Server Stopped",
COMMON$ERROR: "Error",
COMMON$STARTING: "Starting",
COMMON$STOP_RUNTIME: "Stop Runtime",
COMMON$START_RUNTIME: "Start Runtime",
};
return translations[key] || key;
},
i18n: {
changeLanguage: () => new Promise(() => {}),
},
}),
};
});
describe("ServerStatus", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("should render server status with different conversation statuses", () => {
// Test RUNNING status
const { rerender } = renderWithProviders(
<ServerStatus conversationStatus="RUNNING" />,
);
expect(screen.getByText("Running")).toBeInTheDocument();
// Test STOPPED status
rerender(<ServerStatus conversationStatus="STOPPED" />);
expect(screen.getByText("Server Stopped")).toBeInTheDocument();
// Test STARTING status (shows "Running" due to agent state being RUNNING)
rerender(<ServerStatus conversationStatus="STARTING" />);
expect(screen.getByText("Running")).toBeInTheDocument();
// Test null status (shows "Running" due to agent state being RUNNING)
rerender(<ServerStatus conversationStatus={null} />);
expect(screen.getByText("Running")).toBeInTheDocument();
});
it("should show context menu when clicked with RUNNING status", async () => {
const user = userEvent.setup();
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
const statusContainer = screen.getByText("Running").closest("div");
expect(statusContainer).toBeInTheDocument();
await user.click(statusContainer!);
// Context menu should appear
expect(
screen.getByTestId("server-status-context-menu"),
).toBeInTheDocument();
expect(screen.getByTestId("stop-server-button")).toBeInTheDocument();
});
it("should show context menu when clicked with STOPPED status", async () => {
const user = userEvent.setup();
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
const statusContainer = screen.getByText("Server Stopped").closest("div");
expect(statusContainer).toBeInTheDocument();
await user.click(statusContainer!);
// Context menu should appear
expect(
screen.getByTestId("server-status-context-menu"),
).toBeInTheDocument();
expect(screen.getByTestId("start-server-button")).toBeInTheDocument();
});
it("should not show context menu when clicked with other statuses", async () => {
const user = userEvent.setup();
renderWithProviders(<ServerStatus conversationStatus="STARTING" />);
const statusContainer = screen.getByText("Running").closest("div");
expect(statusContainer).toBeInTheDocument();
await user.click(statusContainer!);
// Context menu should not appear
expect(
screen.queryByTestId("server-status-context-menu"),
).not.toBeInTheDocument();
});
it("should call stop conversation mutation when stop server is clicked", async () => {
const user = userEvent.setup();
// Clear previous calls
mockStopConversationMutate.mockClear();
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
const statusContainer = screen.getByText("Running").closest("div");
await user.click(statusContainer!);
const stopButton = screen.getByTestId("stop-server-button");
await user.click(stopButton);
expect(mockStopConversationMutate).toHaveBeenCalledWith({
conversationId: "test-conversation-id",
});
});
it("should call start conversation mutation when start server is clicked", async () => {
const user = userEvent.setup();
// Clear previous calls
mockStartConversationMutate.mockClear();
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
const statusContainer = screen.getByText("Server Stopped").closest("div");
await user.click(statusContainer!);
const startButton = screen.getByTestId("start-server-button");
await user.click(startButton);
expect(mockStartConversationMutate).toHaveBeenCalledWith({
conversationId: "test-conversation-id",
providers: [],
});
});
it("should close context menu after stop server action", async () => {
const user = userEvent.setup();
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
const statusContainer = screen.getByText("Running").closest("div");
await user.click(statusContainer!);
const stopButton = screen.getByTestId("stop-server-button");
await user.click(stopButton);
// Context menu should be closed (handled by the component)
expect(mockStopConversationMutate).toHaveBeenCalledWith({
conversationId: "test-conversation-id",
});
});
it("should close context menu after start server action", async () => {
const user = userEvent.setup();
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
const statusContainer = screen.getByText("Server Stopped").closest("div");
await user.click(statusContainer!);
const startButton = screen.getByTestId("start-server-button");
await user.click(startButton);
// Context menu should be closed
expect(
screen.queryByTestId("server-status-context-menu"),
).not.toBeInTheDocument();
});
it("should handle null conversation status", () => {
renderWithProviders(<ServerStatus conversationStatus={null} />);
const statusText = screen.getByText("Running");
expect(statusText).toBeInTheDocument();
});
});
describe("ServerStatusContextMenu", () => {
const defaultProps = {
onClose: vi.fn(),
conversationStatus: "RUNNING" as ConversationStatus,
};
afterEach(() => {
vi.clearAllMocks();
});
it("should render stop server button when status is RUNNING", () => {
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
conversationStatus="RUNNING"
onStopServer={vi.fn()}
/>,
);
expect(screen.getByTestId("stop-server-button")).toBeInTheDocument();
expect(screen.getByText("Stop Runtime")).toBeInTheDocument();
});
it("should render start server button when status is STOPPED", () => {
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
conversationStatus="STOPPED"
onStartServer={vi.fn()}
/>,
);
expect(screen.getByTestId("start-server-button")).toBeInTheDocument();
expect(screen.getByText("Start Runtime")).toBeInTheDocument();
});
it("should not render stop server button when onStopServer is not provided", () => {
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
conversationStatus="RUNNING"
/>,
);
expect(screen.queryByTestId("stop-server-button")).not.toBeInTheDocument();
});
it("should not render start server button when onStartServer is not provided", () => {
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
conversationStatus="STOPPED"
/>,
);
expect(screen.queryByTestId("start-server-button")).not.toBeInTheDocument();
});
it("should call onStopServer when stop button is clicked", async () => {
const user = userEvent.setup();
const onStopServer = vi.fn();
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
conversationStatus="RUNNING"
onStopServer={onStopServer}
/>,
);
const stopButton = screen.getByTestId("stop-server-button");
await user.click(stopButton);
expect(onStopServer).toHaveBeenCalledTimes(1);
});
it("should call onStartServer when start button is clicked", async () => {
const user = userEvent.setup();
const onStartServer = vi.fn();
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
conversationStatus="STOPPED"
onStartServer={onStartServer}
/>,
);
const startButton = screen.getByTestId("start-server-button");
await user.click(startButton);
expect(onStartServer).toHaveBeenCalledTimes(1);
});
it("should render correct text content for stop server button", () => {
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
conversationStatus="RUNNING"
onStopServer={vi.fn()}
/>,
);
expect(screen.getByTestId("stop-server-button")).toHaveTextContent(
"Stop Runtime",
);
});
it("should render correct text content for start server button", () => {
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
conversationStatus="STOPPED"
onStartServer={vi.fn()}
/>,
);
expect(screen.getByTestId("start-server-button")).toHaveTextContent(
"Start Runtime",
);
});
it("should call onClose when context menu is closed", () => {
const onClose = vi.fn();
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
onClose={onClose}
conversationStatus="RUNNING"
onStopServer={vi.fn()}
/>,
);
// The onClose is typically called by the parent component when clicking outside
// This test verifies the prop is properly passed
expect(onClose).toBeDefined();
});
it("should not render any buttons for other conversation statuses", () => {
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
conversationStatus="STARTING"
/>,
);
expect(screen.queryByTestId("stop-server-button")).not.toBeInTheDocument();
expect(screen.queryByTestId("start-server-button")).not.toBeInTheDocument();
});
});

View File

@@ -1,9 +1,12 @@
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { Provider } from "react-redux";
import { createRoutesStub } from "react-router";
import { setupStore } from "test-utils";
import { describe, expect, it, vi } from "vitest";
import { HomeHeader } from "#/components/features/home/home-header/home-header";
import userEvent from "@testing-library/user-event";
import { HomeHeader } from "#/components/features/home/home-header";
import OpenHands from "#/api/open-hands";
// Mock the translation function
vi.mock("react-i18next", async () => {
@@ -15,6 +18,11 @@ vi.mock("react-i18next", async () => {
// Return a mock translation for the test
const translations: Record<string, string> = {
HOME$LETS_START_BUILDING: "Let's start building",
HOME$LAUNCH_FROM_SCRATCH: "Launch from Scratch",
HOME$LOADING: "Loading...",
HOME$OPENHANDS_DESCRIPTION: "OpenHands is an AI software engineer",
HOME$NOT_SURE_HOW_TO_START: "Not sure how to start?",
HOME$READ_THIS: "Read this",
};
return translations[key] || key;
},
@@ -24,7 +32,18 @@ vi.mock("react-i18next", async () => {
});
const renderHomeHeader = () => {
return render(<HomeHeader />, {
const RouterStub = createRoutesStub([
{
Component: HomeHeader,
path: "/",
},
{
Component: () => <div data-testid="conversation-screen" />,
path: "/conversations/:conversationId",
},
]);
return render(<RouterStub />, {
wrapper: ({ children }) => (
<Provider store={setupStore()}>
<QueryClientProvider client={new QueryClient()}>
@@ -36,25 +55,39 @@ const renderHomeHeader = () => {
};
describe("HomeHeader", () => {
it("should render the header with the correct title", () => {
it("should create an empty conversation and redirect when pressing the launch from scratch button", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
renderHomeHeader();
const title = screen.getByText("Let's start building");
expect(title).toBeInTheDocument();
const launchButton = screen.getByRole("button", {
name: /Launch from Scratch/i,
});
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
);
// expect to be redirected to /conversations/:conversationId
await screen.findByTestId("conversation-screen");
});
it("should render the GuideMessage component", () => {
it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
renderHomeHeader();
// The GuideMessage component should be rendered as part of the header
const header = screen.getByRole("banner");
expect(header).toBeInTheDocument();
});
const launchButton = screen.getByRole("button", {
name: /Launch from Scratch/i,
});
await userEvent.click(launchButton);
it("should have the correct CSS classes for layout", () => {
renderHomeHeader();
const header = screen.getByRole("banner");
expect(header).toHaveClass("flex", "flex-col", "items-center");
expect(launchButton).toHaveTextContent(/Loading.../i);
expect(launchButton).toBeDisabled();
});
});

View File

@@ -1,90 +0,0 @@
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { Provider } from "react-redux";
import { createRoutesStub } from "react-router";
import { setupStore } from "test-utils";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { NewConversation } from "#/components/features/home/new-conversation/new-conversation";
// Mock the translation function
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => {
// Return a mock translation for the test
const translations: Record<string, string> = {
COMMON$START_FROM_SCRATCH: "Start from Scratch",
HOME$NEW_PROJECT_DESCRIPTION: "Create a new project from scratch",
COMMON$NEW_CONVERSATION: "New Conversation",
HOME$LOADING: "Loading...",
};
return translations[key] || key;
},
i18n: { language: "en" },
}),
};
});
const renderNewConversation = () => {
const RouterStub = createRoutesStub([
{
Component: NewConversation,
path: "/",
},
{
Component: () => <div data-testid="conversation-screen" />,
path: "/conversations/:conversationId",
},
]);
return render(<RouterStub />, {
wrapper: ({ children }) => (
<Provider store={setupStore()}>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</Provider>
),
});
};
describe("NewConversation", () => {
it("should create an empty conversation and redirect when pressing the launch from scratch button", async () => {
const createConversationSpy = vi.spyOn(
ConversationService,
"createConversation",
);
renderNewConversation();
const launchButton = screen.getByTestId("launch-new-conversation-button");
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
);
// expect to be redirected to /conversations/:conversationId
await screen.findByTestId("conversation-screen");
});
it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
renderNewConversation();
const launchButton = screen.getByTestId("launch-new-conversation-button");
await userEvent.click(launchButton);
expect(launchButton).toHaveTextContent(/Loading.../i);
expect(launchButton).toBeDisabled();
});
});

View File

@@ -5,10 +5,7 @@ import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { setupStore } from "test-utils";
import { Provider } from "react-redux";
import { createRoutesStub, Outlet } from "react-router";
import SettingsService from "#/settings-service/settings-service.api";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import GitService from "#/api/git-service/git-service.api";
import OptionService from "#/api/option-service/option-service.api";
import OpenHands from "#/api/open-hands";
import { GitRepository } from "#/types/git";
import { RepoConnector } from "#/components/features/home/repo-connector";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
@@ -69,7 +66,7 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
];
beforeEach(() => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
@@ -87,7 +84,7 @@ describe("RepoConnector", () => {
it("should render the available repositories in the dropdown", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
@@ -96,7 +93,7 @@ describe("RepoConnector", () => {
});
// Mock the search function that's used by the dropdown
vi.spyOn(GitService, "searchGitRepositories").mockResolvedValue(
vi.spyOn(OpenHands, "searchGitRepositories").mockResolvedValue(
MOCK_RESPOSITORIES,
);
@@ -124,7 +121,7 @@ describe("RepoConnector", () => {
it("should only enable the launch button if a repo is selected", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
@@ -138,16 +135,10 @@ describe("RepoConnector", () => {
expect(launchButton).toBeDisabled();
// Mock the repository branches API call
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
],
has_next_page: false,
current_page: 1,
per_page: 30,
total_count: 2,
});
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
// First select the provider
const providerDropdown = await waitFor(() =>
@@ -178,15 +169,14 @@ describe("RepoConnector", () => {
expect(launchButton).toBeEnabled();
});
it("should render the 'add github repos' link in dropdown if saas mode and github provider is set", async () => {
const getConfiSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return the APP_MODE and APP_SLUG
it("should render the 'add github repos' link if saas mode and github provider is set", async () => {
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return the APP_MODE
getConfiSpy.mockResolvedValue({
APP_MODE: "saas",
APP_SLUG: "openhands",
});
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
@@ -195,45 +185,19 @@ describe("RepoConnector", () => {
},
});
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
renderRepoConnector();
// First select the GitHub provider
const providerDropdown = await waitFor(() =>
screen.getByTestId("git-provider-dropdown"),
);
await userEvent.click(providerDropdown);
await userEvent.click(screen.getByText("GitHub"));
// Then open the repository dropdown
const repoInput = await waitFor(() =>
screen.getByTestId("git-repo-dropdown"),
);
await userEvent.click(repoInput);
// The "Add GitHub repos" link should be in the dropdown
await waitFor(() => {
expect(screen.getByText("HOME$ADD_GITHUB_REPOS")).toBeInTheDocument();
});
await screen.findByText("HOME$ADD_GITHUB_REPOS");
});
it("should not render the 'add github repos' link if github provider is not set", async () => {
const getConfiSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return the APP_MODE and APP_SLUG
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return the APP_MODE
getConfiSpy.mockResolvedValue({
APP_MODE: "saas",
APP_SLUG: "openhands",
});
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
@@ -242,83 +206,26 @@ describe("RepoConnector", () => {
},
});
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
renderRepoConnector();
// First select the GitLab provider (not GitHub)
const providerDropdown = await waitFor(() =>
screen.getByTestId("git-provider-dropdown"),
);
await userEvent.click(providerDropdown);
await userEvent.click(screen.getByText("GitLab"));
// Then open the repository dropdown
const repoInput = await waitFor(() =>
screen.getByTestId("git-repo-dropdown"),
);
await userEvent.click(repoInput);
// The "Add GitHub repos" link should NOT be in the dropdown for GitLab
expect(screen.queryByText("HOME$ADD_GITHUB_REPOS")).not.toBeInTheDocument();
});
it("should not render the 'add github repos' link in dropdown if oss mode", async () => {
const getConfiSpy = vi.spyOn(OptionService, "getConfig");
it("should not render the 'add git(hub|lab) repos' links if oss mode", async () => {
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return the APP_MODE
getConfiSpy.mockResolvedValue({
APP_MODE: "oss",
});
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: "some-token",
gitlab: null,
},
});
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
renderRepoConnector();
// First select the GitHub provider
const providerDropdown = await waitFor(() =>
screen.getByTestId("git-provider-dropdown"),
);
await userEvent.click(providerDropdown);
await userEvent.click(screen.getByText("GitHub"));
// Then open the repository dropdown
const repoInput = await waitFor(() =>
screen.getByTestId("git-repo-dropdown"),
);
await userEvent.click(repoInput);
// The "Add GitHub repos" link should NOT be in the dropdown for OSS mode
expect(screen.queryByText("HOME$ADD_GITHUB_REPOS")).not.toBeInTheDocument();
expect(screen.queryByText("Add GitHub repos")).not.toBeInTheDocument();
expect(screen.queryByText("Add GitLab repos")).not.toBeInTheDocument();
});
it("should create a conversation and redirect with the selected repo when pressing the launch button", async () => {
const createConversationSpy = vi.spyOn(
ConversationService,
"createConversation",
);
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
createConversationSpy.mockResolvedValue({
conversation_id: "mock-conversation-id",
title: "Test Conversation",
@@ -333,7 +240,7 @@ describe("RepoConnector", () => {
session_api_key: null,
});
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
@@ -352,16 +259,10 @@ describe("RepoConnector", () => {
expect(createConversationSpy).not.toHaveBeenCalled();
// Mock the repository branches API call
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
],
has_next_page: false,
current_page: 1,
per_page: 30,
total_count: 2,
});
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
// First select the provider
const providerDropdown = await waitFor(() =>
@@ -403,13 +304,10 @@ describe("RepoConnector", () => {
});
it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
const createConversationSpy = vi.spyOn(
ConversationService,
"createConversation",
);
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
createConversationSpy.mockImplementation(() => new Promise(() => {})); // Never resolves to keep loading state
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
@@ -418,16 +316,10 @@ describe("RepoConnector", () => {
});
// Mock the repository branches API call
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
],
has_next_page: false,
current_page: 1,
per_page: 30,
total_count: 2,
});
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
renderRepoConnector();
@@ -475,7 +367,7 @@ describe("RepoConnector", () => {
});
it("should display a button to settings if the user needs to sign in with their git provider", async () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {},

View File

@@ -1,9 +1,9 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, vi, beforeEach, it } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { RepositorySelectionForm } from "../../../../src/components/features/home/repo-selection-form";
import UserService from "#/api/user-service/user-service.api";
import GitService from "#/api/git-service/git-service.api";
import OpenHands from "#/api/open-hands";
import { GitRepository } from "#/types/git";
// Create mock functions
@@ -14,7 +14,6 @@ const mockUseTranslation = vi.fn();
const mockUseAuth = vi.fn();
const mockUseGitRepositories = vi.fn();
const mockUseUserProviders = vi.fn();
const mockUseSearchRepositories = vi.fn();
// Setup default mock returns
mockUseUserRepositories.mockReturnValue({
@@ -56,12 +55,6 @@ mockUseUserProviders.mockReturnValue({
providers: ["github"],
});
// Default mock for useSearchRepositories
mockUseSearchRepositories.mockReturnValue({
data: [],
isLoading: false,
});
mockUseAuth.mockReturnValue({
isAuthenticated: true,
isLoading: false,
@@ -94,19 +87,8 @@ vi.mock("#/context/auth-context", () => ({
useAuth: () => mockUseAuth(),
}));
// Mock debounce to simulate proper debounced behavior
let debouncedValue = "";
vi.mock("#/hooks/use-debounce", () => ({
useDebounce: (value: string, _delay: number) => {
// In real debouncing, only the final value after the delay should be returned
// For testing, we'll return the full value once it's complete
if (value && value.length > 20) {
// URL is long enough
debouncedValue = value;
return value;
}
return debouncedValue; // Return previous debounced value for intermediate states
},
useDebounce: (value: string) => value,
}));
vi.mock("react-router", async (importActual) => ({
@@ -118,11 +100,6 @@ vi.mock("#/hooks/query/use-git-repositories", () => ({
useGitRepositories: () => mockUseGitRepositories(),
}));
vi.mock("#/hooks/query/use-search-repositories", () => ({
useSearchRepositories: (query: string, provider: string) =>
mockUseSearchRepositories(query, provider),
}));
const mockOnRepoSelection = vi.fn();
const renderForm = () =>
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />, {
@@ -190,11 +167,30 @@ describe("RepositorySelectionForm", () => {
renderForm();
expect(await screen.findByTestId("dropdown-error")).toBeInTheDocument();
expect(screen.getByText("Failed to load data")).toBeInTheDocument();
expect(
await screen.findByTestId("dropdown-error"),
).toBeInTheDocument();
expect(
screen.getByText("Failed to load data"),
).toBeInTheDocument();
});
it("should call the search repos API when searching a URL", async () => {
const MOCK_REPOS: GitRepository[] = [
{
id: "1",
full_name: "user/repo1",
git_provider: "github",
is_public: true,
},
{
id: "2",
full_name: "user/repo2",
git_provider: "github",
is_public: true,
},
];
const MOCK_SEARCH_REPOS: GitRepository[] = [
{
id: "3",
@@ -204,12 +200,11 @@ describe("RepositorySelectionForm", () => {
},
];
// Create a spy on the API call
const searchGitReposSpy = vi.spyOn(GitService, "searchGitRepositories");
const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories");
searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS);
mockUseGitRepositories.mockReturnValue({
data: { pages: [] },
data: { pages: [{ data: MOCK_REPOS }] },
isLoading: false,
isError: false,
hasNextPage: false,
@@ -218,19 +213,32 @@ describe("RepositorySelectionForm", () => {
onLoadMore: vi.fn(),
});
// Mock search repositories hook to return our mock data
mockUseSearchRepositories.mockReturnValue({
data: MOCK_SEARCH_REPOS,
mockUseAuth.mockReturnValue({
isAuthenticated: true,
isLoading: false,
providersAreSet: true,
user: {
id: 1,
login: "testuser",
avatar_url: "https://example.com/avatar.png",
name: "Test User",
email: "test@example.com",
company: "Test Company",
},
login: vi.fn(),
logout: vi.fn(),
});
renderForm();
const input = await screen.findByTestId("git-repo-dropdown");
// The test should verify that typing a URL triggers the search behavior
// Since the component uses useSearchRepositories hook, just verify the hook is set up correctly
expect(mockUseSearchRepositories).toHaveBeenCalled();
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
"kubernetes/kubernetes",
3,
"github",
);
});
it("should call onRepoSelection when a searched repository is selected", async () => {
@@ -243,6 +251,9 @@ describe("RepositorySelectionForm", () => {
},
];
const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories");
searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS);
mockUseGitRepositories.mockReturnValue({
data: { pages: [{ data: MOCK_SEARCH_REPOS }] },
isLoading: false,
@@ -253,21 +264,15 @@ describe("RepositorySelectionForm", () => {
onLoadMore: vi.fn(),
});
// Mock search repositories hook to return our mock data
mockUseSearchRepositories.mockReturnValue({
data: MOCK_SEARCH_REPOS,
isLoading: false,
});
renderForm();
const input = await screen.findByTestId("git-repo-dropdown");
// Verify that the onRepoSelection callback prop was provided
expect(mockOnRepoSelection).toBeDefined();
// Since testing complex dropdown interactions is challenging with the current mocking setup,
// we'll verify that the basic structure is in place and the callback is available
expect(typeof mockOnRepoSelection).toBe("function");
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
"kubernetes/kubernetes",
3,
"github",
);
});
});

View File

@@ -5,12 +5,10 @@ import userEvent from "@testing-library/user-event";
import { Provider } from "react-redux";
import { createRoutesStub } from "react-router";
import { setupStore } from "test-utils";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import UserService from "#/api/user-service/user-service.api";
import GitService from "#/api/git-service/git-service.api";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import OpenHands from "#/api/open-hands";
import { TaskCard } from "#/components/features/home/tasks/task-card";
import { GitRepository } from "#/types/git";
import { SuggestedTask } from "#/utils/types";
const MOCK_TASK_1: SuggestedTask = {
issue_number: 123,
@@ -59,10 +57,7 @@ describe("TaskCard", () => {
});
it("should call createConversation when clicking the launch button", async () => {
const createConversationSpy = vi.spyOn(
ConversationService,
"createConversation",
);
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
renderTaskCard();
@@ -75,20 +70,14 @@ describe("TaskCard", () => {
describe("creating suggested task conversation", () => {
beforeEach(() => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
retrieveUserGitRepositoriesSpy.mockResolvedValue({ data: MOCK_RESPOSITORIES, nextPage: null });
});
it("should call create conversation with suggest task trigger and selected suggested task", async () => {
const createConversationSpy = vi.spyOn(
ConversationService,
"createConversation",
);
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
renderTaskCard(MOCK_TASK_1);
@@ -113,11 +102,18 @@ describe("TaskCard", () => {
});
});
it("should disable the launch button and update text content when creating a conversation", async () => {
renderTaskCard();
const launchButton = screen.getByTestId("task-launch-button");
await userEvent.click(launchButton);
expect(launchButton).toHaveTextContent(/Loading/i);
expect(launchButton).toBeDisabled();
});
it("should navigate to the conversation page after creating a conversation", async () => {
const createConversationSpy = vi.spyOn(
ConversationService,
"createConversation",
);
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
createConversationSpy.mockResolvedValue({
conversation_id: "test-conversation-id",
title: "Test Conversation",
@@ -129,7 +125,7 @@ describe("TaskCard", () => {
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null,
session_api_key: null
});
renderTaskCard();

View File

@@ -1,4 +1,4 @@
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Provider } from "react-redux";
@@ -7,6 +7,7 @@ import { setupStore } from "test-utils";
import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions";
import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api";
import { MOCK_TASKS } from "#/mocks/task-suggestions-handlers";
import userEvent from "@testing-library/user-event";
// Mock the translation function
vi.mock("react-i18next", async () => {
@@ -22,28 +23,6 @@ vi.mock("react-i18next", async () => {
};
});
// Mock the dependencies for useShouldShowUserFeatures
vi.mock("#/hooks/query/use-is-authed", () => ({
useIsAuthed: () => ({
data: true,
isLoading: false,
}),
}));
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => ({
data: { APP_MODE: "saas" },
isLoading: false,
}),
}));
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => ({
providers: [{ id: "github", name: "GitHub" }],
isLoading: false,
}),
}));
const renderTaskSuggestions = () => {
const RouterStub = createRoutesStub([
{
@@ -97,9 +76,9 @@ describe("TaskSuggestions", () => {
renderTaskSuggestions();
await waitFor(() => {
// Check for repository names (grouped by repo) - only the first 3 tasks are shown
screen.getByText("octocat/hello-world");
screen.getByText("octocat/earth");
MOCK_TASKS.forEach((taskGroup) => {
screen.getByText(taskGroup.title);
});
});
});
@@ -108,11 +87,9 @@ describe("TaskSuggestions", () => {
renderTaskSuggestions();
await waitFor(() => {
// Only check for the first 3 tasks that are actually rendered
// The component limits to 3 tasks due to getLimitedTaskGroups function
screen.getByText("Fix merge conflicts"); // First task from octocat/hello-world
screen.getByText("Fix broken CI checks"); // First task from octocat/earth
screen.getByText("Fix issue"); // Second task from octocat/earth
MOCK_TASKS.forEach((task) => {
screen.getByText(task.title);
});
});
});
@@ -124,11 +101,33 @@ describe("TaskSuggestions", () => {
expect(skeletons.length).toBeGreaterThan(0);
await waitFor(() => {
// Check for repository names (grouped by repo) - only the first 3 tasks are shown
screen.getByText("octocat/hello-world");
screen.getByText("octocat/earth");
MOCK_TASKS.forEach((taskGroup) => {
screen.getByText(taskGroup.title);
});
});
expect(screen.queryByTestId("task-group-skeleton")).not.toBeInTheDocument();
});
it("should render the tooltip button", () => {
renderTaskSuggestions();
const tooltipButton = screen.getByTestId("task-suggestions-info");
expect(tooltipButton).toBeInTheDocument();
});
it("should have the correct aria-label", () => {
renderTaskSuggestions();
const tooltipButton = screen.getByTestId("task-suggestions-info");
expect(tooltipButton).toHaveAttribute(
"aria-label",
"TASKS$TASK_SUGGESTIONS_INFO",
);
});
it("should render the info icon", () => {
renderTaskSuggestions();
const tooltipButton = screen.getByTestId("task-suggestions-info");
const icon = tooltipButton.querySelector("svg");
expect(icon).toBeInTheDocument();
});
});

View File

@@ -1,7 +1,6 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { act } from "react";
import { MemoryRouter } from "react-router";
import { MaintenanceBanner } from "#/components/features/maintenance/maintenance-banner";
// Mock react-i18next
@@ -29,11 +28,7 @@ describe("MaintenanceBanner", () => {
it("renders maintenance banner with formatted time", () => {
const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp
const { container } = render(
<MemoryRouter>
<MaintenanceBanner startTime={startTime} />
</MemoryRouter>,
);
const { container } = render(<MaintenanceBanner startTime={startTime} />);
// Check if the banner is rendered
const banner = screen.queryByTestId("maintenance-banner");
@@ -53,11 +48,7 @@ describe("MaintenanceBanner", () => {
it("handles invalid date gracefully", () => {
const invalidTime = "invalid-date";
render(
<MemoryRouter>
<MaintenanceBanner startTime={invalidTime} />
</MemoryRouter>,
);
render(<MaintenanceBanner startTime={invalidTime} />);
// Check if the banner is rendered
const banner = screen.queryByTestId("maintenance-banner");
@@ -67,11 +58,7 @@ describe("MaintenanceBanner", () => {
it("click on dismiss button removes banner", () => {
const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp
render(
<MemoryRouter>
<MaintenanceBanner startTime={startTime} />
</MemoryRouter>,
);
render(<MaintenanceBanner startTime={startTime} />);
// Check if the banner is rendered
const banner = screen.queryByTestId("maintenance-banner");
@@ -87,11 +74,7 @@ describe("MaintenanceBanner", () => {
const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp
const nextStartTime = "2025-01-15T10:00:00-05:00"; // EST timestamp
const { rerender } = render(
<MemoryRouter>
<MaintenanceBanner startTime={startTime} />
</MemoryRouter>,
);
const { rerender } = render(<MaintenanceBanner startTime={startTime} />);
// Check if the banner is rendered
const banner = screen.queryByTestId("maintenance-banner");
@@ -102,12 +85,27 @@ describe("MaintenanceBanner", () => {
});
expect(banner).not.toBeInTheDocument();
rerender(
<MemoryRouter>
<MaintenanceBanner startTime={nextStartTime} />
</MemoryRouter>,
);
rerender(<MaintenanceBanner startTime={nextStartTime} />);
expect(screen.queryByTestId("maintenance-banner")).toBeInTheDocument();
});
it("banner doesn't reappear after dismissing on next maintenance event(past time)", () => {
const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp
const nextStartTime = "2023-01-15T10:00:00-05:00"; // EST timestamp
const { rerender } = render(<MaintenanceBanner startTime={startTime} />);
// Check if the banner is rendered
const banner = screen.queryByTestId("maintenance-banner");
const button = within(banner!).queryByTestId("dismiss-button");
act(() => {
fireEvent.click(button!);
});
expect(banner).not.toBeInTheDocument();
rerender(<MaintenanceBanner startTime={nextStartTime} />);
expect(screen.queryByTestId("maintenance-banner")).not.toBeInTheDocument();
});
});

View File

@@ -7,8 +7,7 @@ import React from "react";
import { renderWithProviders } from "test-utils";
import MicroagentManagement from "#/routes/microagent-management";
import { MicroagentManagementMain } from "#/components/features/microagent-management/microagent-management-main";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import GitService from "#/api/git-service/git-service.api";
import OpenHands from "#/api/open-hands";
import { GitRepository } from "#/types/git";
import { RepositoryMicroagent } from "#/types/microagent-management";
import { Conversation } from "#/api/open-hands.types";
@@ -232,20 +231,20 @@ describe("MicroagentManagement", () => {
});
// Setup default mock for retrieveUserGitRepositories
vi.spyOn(GitService, "retrieveUserGitRepositories").mockResolvedValue({
vi.spyOn(OpenHands, "retrieveUserGitRepositories").mockResolvedValue({
data: [...mockRepositories],
nextPage: null,
});
// Setup default mock for getRepositoryMicroagents
vi.spyOn(GitService, "getRepositoryMicroagents").mockResolvedValue([
vi.spyOn(OpenHands, "getRepositoryMicroagents").mockResolvedValue([
...mockMicroagents,
]);
// Setup default mock for searchConversations
vi.spyOn(ConversationService, "searchConversations").mockResolvedValue([
vi.spyOn(OpenHands, "searchConversations").mockResolvedValue([
...mockConversations,
]);
// Setup default mock for getRepositoryMicroagentContent
vi.spyOn(GitService, "getRepositoryMicroagentContent").mockResolvedValue({
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
content: "Original microagent content for testing updates",
path: ".openhands/microagents/update-test-microagent",
git_provider: "github",
@@ -1291,7 +1290,7 @@ describe("MicroagentManagement", () => {
// Add microagent integration tests
describe("Add microagent functionality", () => {
beforeEach(() => {
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({
branches: [{ name: "main", commit_sha: "abc123", protected: false }],
has_next_page: false,
current_page: 1,
@@ -1984,7 +1983,7 @@ describe("MicroagentManagement", () => {
};
beforeEach(() => {
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({
branches: [{ name: "main", commit_sha: "abc123", protected: false }],
has_next_page: false,
current_page: 1,
@@ -2315,7 +2314,7 @@ describe("MicroagentManagement", () => {
const user = userEvent.setup();
// Mock the content API to return empty content for this test
vi.spyOn(GitService, "getRepositoryMicroagentContent").mockResolvedValue({
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
content: "",
path: ".openhands/microagents/update-test-microagent",
git_provider: "github",
@@ -2364,7 +2363,7 @@ describe("MicroagentManagement", () => {
const user = userEvent.setup();
// Mock the content API to return content without triggers for this test
vi.spyOn(GitService, "getRepositoryMicroagentContent").mockResolvedValue({
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
content: "Original microagent content for testing updates",
path: ".openhands/microagents/update-test-microagent",
git_provider: "github",
@@ -2648,7 +2647,7 @@ describe("MicroagentManagement", () => {
const user = userEvent.setup();
// Mock the content API to return the expected content for this test
vi.spyOn(GitService, "getRepositoryMicroagentContent").mockResolvedValue({
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
content: "Test microagent content for learn functionality",
path: ".openhands/microagents/learn-test-microagent",
git_provider: "github",
@@ -2708,7 +2707,7 @@ describe("MicroagentManagement", () => {
const user = userEvent.setup();
// Mock the content API to return empty content for this test
vi.spyOn(GitService, "getRepositoryMicroagentContent").mockResolvedValue({
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
content: "",
path: ".openhands/microagents/learn-test-microagent",
git_provider: "github",
@@ -2766,7 +2765,7 @@ describe("MicroagentManagement", () => {
const user = userEvent.setup();
// Mock the content API to return content without triggers for this test
vi.spyOn(GitService, "getRepositoryMicroagentContent").mockResolvedValue({
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
content: "Test microagent content for learn functionality",
path: ".openhands/microagents/learn-test-microagent",
git_provider: "github",

View File

@@ -1,30 +1,23 @@
import { screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
import BillingService from "#/api/billing-service/billing-service.api";
import OptionService from "#/api/option-service/option-service.api";
import OpenHands from "#/api/open-hands";
import { PaymentForm } from "#/components/features/payment/payment-form";
import { renderWithProviders } from "../../../../test-utils";
// Mock the stripe checkout hook to avoid JSDOM navigation issues
const mockMutate = vi.fn().mockResolvedValue(undefined);
vi.mock("#/hooks/mutation/stripe/use-create-stripe-checkout-session", () => ({
useCreateStripeCheckoutSession: () => ({
mutate: mockMutate,
mutateAsync: vi.fn().mockResolvedValue(undefined),
isPending: false,
}),
}));
describe("PaymentForm", () => {
const getBalanceSpy = vi.spyOn(BillingService, "getBalance");
const createCheckoutSessionSpy = vi.spyOn(
BillingService,
"createCheckoutSession",
);
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getBalanceSpy = vi.spyOn(OpenHands, "getBalance");
const createCheckoutSessionSpy = vi.spyOn(OpenHands, "createCheckoutSession");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const renderPaymentForm = () => renderWithProviders(<PaymentForm />);
const renderPaymentForm = () =>
render(<PaymentForm />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
beforeEach(() => {
// useBalance hook will return the balance only if the APP_MODE is "saas" and the billing feature is enabled
@@ -44,7 +37,6 @@ describe("PaymentForm", () => {
afterEach(() => {
vi.clearAllMocks();
mockMutate.mockClear();
});
it("should render the users current balance", async () => {
@@ -77,7 +69,7 @@ describe("PaymentForm", () => {
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
await user.click(topUpButton);
expect(mockMutate).toHaveBeenCalledWith({ amount: 50 });
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50);
});
it("should only accept integer values", async () => {
@@ -90,7 +82,7 @@ describe("PaymentForm", () => {
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
await user.click(topUpButton);
expect(mockMutate).toHaveBeenCalledWith({ amount: 50 });
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50);
});
it("should disable the top-up button if the user enters an invalid amount", async () => {
@@ -130,7 +122,7 @@ describe("PaymentForm", () => {
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
await user.click(topUpButton);
expect(mockMutate).not.toHaveBeenCalled();
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
});
test("user enters an empty string", async () => {
@@ -143,7 +135,7 @@ describe("PaymentForm", () => {
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
await user.click(topUpButton);
expect(mockMutate).not.toHaveBeenCalled();
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
});
test("user enters a non-numeric value", async () => {
@@ -158,7 +150,7 @@ describe("PaymentForm", () => {
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
await user.click(topUpButton);
expect(mockMutate).not.toHaveBeenCalled();
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
});
test("user enters less than the minimum amount", async () => {
@@ -171,7 +163,7 @@ describe("PaymentForm", () => {
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
await user.click(topUpButton);
expect(mockMutate).not.toHaveBeenCalled();
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
});
test("user enters a decimal value", async () => {
@@ -185,175 +177,7 @@ describe("PaymentForm", () => {
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
await user.click(topUpButton);
expect(mockMutate).not.toHaveBeenCalled();
});
});
describe("Cancel Subscription", () => {
const getSubscriptionAccessSpy = vi.spyOn(
BillingService,
"getSubscriptionAccess",
);
const cancelSubscriptionSpy = vi.spyOn(
BillingService,
"cancelSubscription",
);
beforeEach(() => {
// Mock active subscription
getSubscriptionAccessSpy.mockResolvedValue({
start_at: "2024-01-01T00:00:00Z",
end_at: "2024-12-31T23:59:59Z",
created_at: "2024-01-01T00:00:00Z",
});
});
it("should render cancel subscription button when user has active subscription", async () => {
renderPaymentForm();
await waitFor(() => {
const cancelButton = screen.getByTestId("cancel-subscription-button");
expect(cancelButton).toBeInTheDocument();
expect(cancelButton).toHaveTextContent("PAYMENT$CANCEL_SUBSCRIPTION");
});
});
it("should not render cancel subscription button when user has no subscription", async () => {
getSubscriptionAccessSpy.mockResolvedValue(null);
renderPaymentForm();
await waitFor(() => {
const cancelButton = screen.queryByTestId("cancel-subscription-button");
expect(cancelButton).not.toBeInTheDocument();
});
});
it("should show confirmation modal when cancel subscription button is clicked", async () => {
const user = userEvent.setup();
renderPaymentForm();
const cancelButton = await screen.findByTestId(
"cancel-subscription-button",
);
await user.click(cancelButton);
// Should show confirmation modal
expect(
screen.getByTestId("cancel-subscription-modal"),
).toBeInTheDocument();
expect(
screen.getByText("PAYMENT$CANCEL_SUBSCRIPTION_TITLE"),
).toBeInTheDocument();
// The message should be rendered (either with Trans component or regular text)
const modalContent = screen.getByTestId("cancel-subscription-modal");
expect(modalContent).toBeInTheDocument();
expect(screen.getByTestId("confirm-cancel-button")).toBeInTheDocument();
expect(screen.getByTestId("modal-cancel-button")).toBeInTheDocument();
});
it("should close modal when cancel button in modal is clicked", async () => {
const user = userEvent.setup();
renderPaymentForm();
const cancelButton = await screen.findByTestId(
"cancel-subscription-button",
);
await user.click(cancelButton);
// Modal should be visible
expect(
screen.getByTestId("cancel-subscription-modal"),
).toBeInTheDocument();
// Click cancel in modal
const modalCancelButton = screen.getByTestId("modal-cancel-button");
await user.click(modalCancelButton);
// Modal should be closed
expect(
screen.queryByTestId("cancel-subscription-modal"),
).not.toBeInTheDocument();
});
it("should call cancel subscription API when confirm button is clicked", async () => {
const user = userEvent.setup();
renderPaymentForm();
const cancelButton = await screen.findByTestId(
"cancel-subscription-button",
);
await user.click(cancelButton);
// Click confirm in modal
const confirmButton = screen.getByTestId("confirm-cancel-button");
await user.click(confirmButton);
// Should call the cancel subscription API
expect(cancelSubscriptionSpy).toHaveBeenCalled();
});
it("should close modal after successful cancellation", async () => {
const user = userEvent.setup();
cancelSubscriptionSpy.mockResolvedValue({
status: "success",
message: "Subscription cancelled successfully",
});
renderPaymentForm();
const cancelButton = await screen.findByTestId(
"cancel-subscription-button",
);
await user.click(cancelButton);
const confirmButton = screen.getByTestId("confirm-cancel-button");
await user.click(confirmButton);
// Wait for API call to complete and modal to close
await waitFor(() => {
expect(
screen.queryByTestId("cancel-subscription-modal"),
).not.toBeInTheDocument();
});
});
it("should show next billing date for active subscription", async () => {
// Mock active subscription with end_at as next billing date
getSubscriptionAccessSpy.mockResolvedValue({
start_at: "2024-01-01T00:00:00Z",
end_at: "2025-01-01T00:00:00Z",
created_at: "2024-01-01T00:00:00Z",
cancelled_at: null,
stripe_subscription_id: "sub_123",
});
renderPaymentForm();
await waitFor(() => {
const nextBillingInfo = screen.getByTestId("next-billing-date");
expect(nextBillingInfo).toBeInTheDocument();
// Check that it contains some date-related content (translation key or actual date)
expect(nextBillingInfo).toHaveTextContent(
/2025|PAYMENT.*BILLING.*DATE/,
);
});
});
it("should not show next billing date when subscription is cancelled", async () => {
// Mock cancelled subscription
getSubscriptionAccessSpy.mockResolvedValue({
start_at: "2024-01-01T00:00:00Z",
end_at: "2025-01-01T00:00:00Z",
created_at: "2024-01-01T00:00:00Z",
cancelled_at: "2024-06-15T10:30:00Z",
stripe_subscription_id: "sub_123",
});
renderPaymentForm();
await waitFor(() => {
const nextBillingInfo = screen.queryByTestId("next-billing-date");
expect(nextBillingInfo).not.toBeInTheDocument();
});
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
});
});
});

View File

@@ -3,7 +3,7 @@ import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { waitFor } from "@testing-library/react";
import { Sidebar } from "#/components/features/sidebar/sidebar";
import SettingsService from "#/settings-service/settings-service.api";
import OpenHands from "#/api/open-hands";
// These tests will now fail because the conversation panel is rendered through a portal
// and technically not a child of the Sidebar component.
@@ -19,7 +19,7 @@ const renderSidebar = () =>
renderWithProviders(<RouterStub initialEntries={["/conversation/123"]} />);
describe("Sidebar", () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
afterEach(() => {
vi.clearAllMocks();

View File

@@ -8,6 +8,7 @@ describe("TrajectoryActions", () => {
const user = userEvent.setup();
const onPositiveFeedback = vi.fn();
const onNegativeFeedback = vi.fn();
const onExportTrajectory = vi.fn();
afterEach(() => {
vi.clearAllMocks();
@@ -18,12 +19,14 @@ describe("TrajectoryActions", () => {
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
const actions = screen.getByTestId("feedback-actions");
within(actions).getByTestId("positive-feedback");
within(actions).getByTestId("negative-feedback");
within(actions).getByTestId("export-trajectory");
});
it("should call onPositiveFeedback when positive feedback is clicked", async () => {
@@ -31,6 +34,7 @@ describe("TrajectoryActions", () => {
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
@@ -45,6 +49,7 @@ describe("TrajectoryActions", () => {
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
@@ -54,12 +59,48 @@ describe("TrajectoryActions", () => {
expect(onNegativeFeedback).toHaveBeenCalled();
});
it("should call onExportTrajectory when export button is clicked", async () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
const exportButton = screen.getByTestId("export-trajectory");
await user.click(exportButton);
expect(onExportTrajectory).toHaveBeenCalled();
});
describe("SaaS mode", () => {
it("should only render export button when isSaasMode is true", () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
isSaasMode={true}
/>,
);
const actions = screen.getByTestId("feedback-actions");
// Should not render feedback buttons in SaaS mode
expect(within(actions).queryByTestId("positive-feedback")).toBeNull();
expect(within(actions).queryByTestId("negative-feedback")).toBeNull();
// Should still render export button
within(actions).getByTestId("export-trajectory");
});
it("should render all buttons when isSaasMode is false", () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
isSaasMode={false}
/>,
);
@@ -67,6 +108,7 @@ describe("TrajectoryActions", () => {
const actions = screen.getByTestId("feedback-actions");
within(actions).getByTestId("positive-feedback");
within(actions).getByTestId("negative-feedback");
within(actions).getByTestId("export-trajectory");
});
it("should render all buttons when isSaasMode is undefined (default behavior)", () => {
@@ -74,12 +116,30 @@ describe("TrajectoryActions", () => {
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
const actions = screen.getByTestId("feedback-actions");
within(actions).getByTestId("positive-feedback");
within(actions).getByTestId("negative-feedback");
within(actions).getByTestId("export-trajectory");
});
it("should call onExportTrajectory when export button is clicked in SaaS mode", async () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
isSaasMode={true}
/>,
);
const exportButton = screen.getByTestId("export-trajectory");
await user.click(exportButton);
expect(onExportTrajectory).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
describe("File Operations Messages", () => {
it.todo("should show success indicator for successful file read operation");
it.todo("should show failure indicator for failed file read operation");
it.todo("should show success indicator for successful file edit operation");
it.todo("should show failure indicator for failed file edit operation");
});

View File

@@ -1,62 +1,12 @@
import { screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { MemoryRouter } from "react-router";
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
import { renderWithProviders } from "../../test-utils";
import { AgentState } from "#/types/agent-state";
// Mock React Router hooks
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...actual,
useNavigate: () => vi.fn(),
useParams: () => ({ conversationId: "test-conversation-id" }),
};
});
// Mock the useActiveConversation hook
vi.mock("#/hooks/query/use-active-conversation", () => ({
useActiveConversation: () => ({
data: { status: null },
isFetched: true,
refetch: vi.fn(),
}),
}));
// Mock other hooks that might be used by the component
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => ({
providers: [],
}),
}));
vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
useConversationNameContextMenu: () => ({
isOpen: false,
contextMenuRef: { current: null },
handleContextMenu: vi.fn(),
handleClose: vi.fn(),
handleRename: vi.fn(),
handleDelete: vi.fn(),
}),
}));
describe("InteractiveChatBox", () => {
const onSubmitMock = vi.fn();
const onStopMock = vi.fn();
// Helper function to render with Router context
const renderInteractiveChatBox = (props: any, options: any = {}) => {
return renderWithProviders(
<MemoryRouter>
<InteractiveChatBox {...props} />
</MemoryRouter>,
options,
);
};
beforeAll(() => {
global.URL.createObjectURL = vi
.fn()
@@ -68,221 +18,111 @@ describe("InteractiveChatBox", () => {
});
it("should render", () => {
renderInteractiveChatBox(
{
onSubmit: onSubmitMock,
onStop: onStopMock,
isWaitingForUserInput: false,
hasSubstantiveAgentActions: false,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.INIT,
},
},
},
render(<InteractiveChatBox onSubmit={onSubmitMock} onStop={onStopMock} />);
const chatBox = screen.getByTestId("interactive-chat-box");
within(chatBox).getByTestId("chat-input");
within(chatBox).getByTestId("upload-image-input");
});
it.fails("should set custom values", () => {
render(
<InteractiveChatBox
onSubmit={onSubmitMock}
onStop={onStopMock}
value="Hello, world!"
/>,
);
const chatBox = screen.getByTestId("interactive-chat-box");
expect(chatBox).toBeInTheDocument();
});
const chatInput = within(chatBox).getByTestId("chat-input");
it("should set custom values", async () => {
const user = userEvent.setup();
renderInteractiveChatBox(
{
onSubmit: onSubmitMock,
onStop: onStopMock,
isWaitingForUserInput: true,
hasSubstantiveAgentActions: true,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.AWAITING_USER_INPUT,
},
conversation: {
isRightPanelShown: true,
shouldStopConversation: false,
shouldStartConversation: false,
images: [],
files: [],
loadingFiles: [],
loadingImages: [],
messageToSend: null,
shouldShownAgentLoading: false,
},
},
},
);
const textbox = screen.getByTestId("chat-input");
// Simulate user typing to populate the input
await user.type(textbox, "Hello, world!");
expect(textbox).toHaveTextContent("Hello, world!");
expect(chatInput).toHaveValue("Hello, world!");
});
it("should display the image previews when images are uploaded", async () => {
const user = userEvent.setup();
renderInteractiveChatBox(
{
onSubmit: onSubmitMock,
onStop: onStopMock,
isWaitingForUserInput: false,
hasSubstantiveAgentActions: false,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.INIT,
},
},
},
);
render(<InteractiveChatBox onSubmit={onSubmitMock} onStop={onStopMock} />);
// Create a larger file to ensure it passes validation
const fileContent = new Array(1024).fill("a").join(""); // 1KB file
const file = new File([fileContent], "chucknorris.png", {
type: "image/png",
});
// Click on the paperclip icon to trigger file selection
const paperclipIcon = screen.getByTestId("paperclip-icon");
await user.click(paperclipIcon);
// Now trigger the file input change event directly
const file = new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" });
const input = screen.getByTestId("upload-image-input");
await user.upload(input, file);
// For now, just verify the file input is accessible
expect(input).toBeInTheDocument();
expect(screen.queryAllByTestId("image-preview")).toHaveLength(0);
await user.upload(input, file);
expect(screen.queryAllByTestId("image-preview")).toHaveLength(1);
const files = [
new File(["(⌐□_□)"], "chucknorris2.png", { type: "image/png" }),
new File(["(⌐□_□)"], "chucknorris3.png", { type: "image/png" }),
];
await user.upload(input, files);
expect(screen.queryAllByTestId("image-preview")).toHaveLength(3);
});
it("should remove the image preview when the close button is clicked", async () => {
const user = userEvent.setup();
renderInteractiveChatBox(
{
onSubmit: onSubmitMock,
onStop: onStopMock,
isWaitingForUserInput: false,
hasSubstantiveAgentActions: false,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.INIT,
},
},
},
);
const fileContent = new Array(1024).fill("a").join(""); // 1KB file
const file = new File([fileContent], "chucknorris.png", {
type: "image/png",
});
// Click on the paperclip icon to trigger file selection
const paperclipIcon = screen.getByTestId("paperclip-icon");
await user.click(paperclipIcon);
render(<InteractiveChatBox onSubmit={onSubmitMock} onStop={onStopMock} />);
const file = new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" });
const input = screen.getByTestId("upload-image-input");
await user.upload(input, file);
// For now, just verify the file input is accessible
expect(input).toBeInTheDocument();
await user.upload(input, file);
expect(screen.queryAllByTestId("image-preview")).toHaveLength(1);
const imagePreview = screen.getByTestId("image-preview");
const closeButton = within(imagePreview).getByRole("button");
await user.click(closeButton);
expect(screen.queryAllByTestId("image-preview")).toHaveLength(0);
});
it("should call onSubmit with the message and images", async () => {
const user = userEvent.setup();
renderInteractiveChatBox(
{
onSubmit: onSubmitMock,
onStop: onStopMock,
isWaitingForUserInput: false,
hasSubstantiveAgentActions: false,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.INIT,
},
},
},
render(<InteractiveChatBox onSubmit={onSubmitMock} onStop={onStopMock} />);
const textarea = within(screen.getByTestId("chat-input")).getByRole(
"textbox",
);
const input = screen.getByTestId("upload-image-input");
const file = new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" });
const textarea = screen.getByTestId("chat-input");
// Type the message and ensure it's properly set
await user.upload(input, file);
await user.type(textarea, "Hello, world!");
await user.keyboard("{Enter}");
// Set innerText directly as the component reads this property
textarea.innerText = "Hello, world!";
expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!", [file], []);
// Verify the text is in the input before submitting
expect(textarea).toHaveTextContent("Hello, world!");
// Click the submit button instead of pressing Enter for more reliable testing
const submitButton = screen.getByTestId("submit-button");
// Verify the button is enabled before clicking
expect(submitButton).not.toBeDisabled();
await user.click(submitButton);
expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!", [], []);
// clear images after submission
expect(screen.queryAllByTestId("image-preview")).toHaveLength(0);
});
it("should disable the submit button when agent is loading", async () => {
it("should disable the submit button", async () => {
const user = userEvent.setup();
renderInteractiveChatBox(
{
onSubmit: onSubmitMock,
onStop: onStopMock,
isWaitingForUserInput: false,
hasSubstantiveAgentActions: false,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.LOADING,
},
},
},
render(
<InteractiveChatBox
isDisabled
onSubmit={onSubmitMock}
onStop={onStopMock}
/>,
);
const button = screen.getByTestId("submit-button");
const button = screen.getByRole("button");
expect(button).toBeDisabled();
await user.click(button);
expect(onSubmitMock).not.toHaveBeenCalled();
});
it("should display the stop button when agent is running and call onStop when clicked", async () => {
it("should display the stop button if set and call onStop when clicked", async () => {
const user = userEvent.setup();
renderInteractiveChatBox(
{
onSubmit: onSubmitMock,
onStop: onStopMock,
isWaitingForUserInput: false,
hasSubstantiveAgentActions: true,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.RUNNING,
},
},
},
render(
<InteractiveChatBox
mode="stop"
onSubmit={onSubmitMock}
onStop={onStopMock}
/>,
);
const stopButton = screen.getByTestId("stop-button");
@@ -296,63 +136,55 @@ describe("InteractiveChatBox", () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
const onStop = vi.fn();
const onChange = vi.fn();
const { rerender } = renderInteractiveChatBox(
{
onSubmit: onSubmit,
onStop: onStop,
isWaitingForUserInput: true,
hasSubstantiveAgentActions: true,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.AWAITING_USER_INPUT,
},
conversation: {
isRightPanelShown: true,
shouldStopConversation: false,
shouldStartConversation: false,
images: [],
files: [],
loadingFiles: [],
loadingImages: [],
messageToSend: null,
shouldShownAgentLoading: false,
},
},
},
const { rerender } = render(
<InteractiveChatBox
onSubmit={onSubmit}
onStop={onStop}
onChange={onChange}
value="test message"
/>,
);
// Verify text input has the initial value
const textarea = screen.getByTestId("chat-input");
expect(textarea).toHaveTextContent("");
// Upload an image via the upload button - this should NOT clear the text input
const file = new File(["dummy content"], "test.png", { type: "image/png" });
const input = screen.getByTestId("upload-image-input");
await user.upload(input, file);
// Set innerText directly as the component reads this property
textarea.innerText = "test message";
// Verify text input was not cleared
expect(screen.getByRole("textbox")).toHaveValue("test message");
expect(onChange).not.toHaveBeenCalledWith("");
// Submit the message
const submitButton = screen.getByTestId("submit-button");
// Submit the message with image
const submitButton = screen.getByRole("button", { name: "BUTTON$SEND" });
await user.click(submitButton);
// Verify onSubmit was called with the message
expect(onSubmit).toHaveBeenCalledWith("test message", [], []);
// Verify onSubmit was called with the message and image
expect(onSubmit).toHaveBeenCalledWith("test message", [file], []);
// Verify onChange was called to clear the text input
expect(onChange).toHaveBeenCalledWith("");
// Simulate parent component updating the value prop
rerender(
<MemoryRouter>
<InteractiveChatBox
onSubmit={onSubmit}
onStop={onStop}
isWaitingForUserInput={true}
hasSubstantiveAgentActions={true}
optimisticUserMessage={false}
/>
</MemoryRouter>,
<InteractiveChatBox
onSubmit={onSubmit}
onStop={onStop}
onChange={onChange}
value=""
/>,
);
// Verify the text input was cleared
expect(screen.getByTestId("chat-input")).toHaveTextContent("");
expect(screen.getByRole("textbox")).toHaveValue("");
// Upload another image - this should NOT clear the text input
onChange.mockClear();
await user.upload(input, file);
// Verify text input is still empty and onChange was not called
expect(screen.getByRole("textbox")).toHaveValue("");
expect(onChange).not.toHaveBeenCalled();
});
});

View File

@@ -5,13 +5,7 @@ import translations from "../../src/i18n/translation.json";
import { UserAvatar } from "../../src/components/features/sidebar/user-avatar";
vi.mock("@heroui/react", () => ({
Tooltip: ({
content,
children,
}: {
content: string;
children: React.ReactNode;
}) => (
Tooltip: ({ content, children }: { content: string; children: React.ReactNode }) => (
<div>
{children}
<div>{content}</div>
@@ -19,33 +13,15 @@ vi.mock("@heroui/react", () => ({
),
}));
const supportedLanguages = [
"en",
"ja",
"zh-CN",
"zh-TW",
"ko-KR",
"de",
"no",
"it",
"pt",
"es",
"ar",
"fr",
"tr",
];
const supportedLanguages = ['en', 'ja', 'zh-CN', 'zh-TW', 'ko-KR', 'de', 'no', 'it', 'pt', 'es', 'ar', 'fr', 'tr'];
// Helper function to check if a translation exists for all supported languages
function checkTranslationExists(key: string) {
const missingTranslations: string[] = [];
const translationEntry = (
translations as Record<string, Record<string, string>>
)[key];
const translationEntry = (translations as Record<string, Record<string, string>>)[key];
if (!translationEntry) {
throw new Error(
`Translation key "${key}" does not exist in translation.json`,
);
throw new Error(`Translation key "${key}" does not exist in translation.json`);
}
for (const lang of supportedLanguages) {
@@ -77,9 +53,7 @@ function findDuplicateKeys(obj: Record<string, any>) {
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => {
const translationEntry = (
translations as Record<string, Record<string, string>>
)[key];
const translationEntry = (translations as Record<string, Record<string, string>>)[key];
return translationEntry?.ja || key;
},
}),
@@ -128,13 +102,16 @@ describe("Landing page translations", () => {
// Check main content translations
expect(screen.getByText("開発を始めましょう!")).toBeInTheDocument();
expect(screen.getByText("VS Codeで開く")).toBeInTheDocument();
expect(
screen.getByText("テストカバレッジを向上させる"),
).toBeInTheDocument();
expect(screen.getByText("テストカバレッジを向上させる")).toBeInTheDocument();
expect(screen.getByText("Dependabot PRを自動マージ")).toBeInTheDocument();
expect(screen.getByText("READMEを改善")).toBeInTheDocument();
expect(screen.getByText("依存関係を整理")).toBeInTheDocument();
// Check user avatar tooltip
const userAvatar = screen.getByTestId("user-avatar");
userAvatar.focus();
expect(screen.getByText("アカウント設定")).toBeInTheDocument();
// Check tab labels
const tabs = screen.getByTestId("tabs");
expect(tabs).toHaveTextContent("ターミナル");
@@ -143,12 +120,8 @@ describe("Landing page translations", () => {
expect(tabs).toHaveTextContent("コードエディタ");
// Check workspace label and new project button
expect(screen.getByTestId("workspace-label")).toHaveTextContent(
"ワークスペース",
);
expect(screen.getByTestId("new-project")).toHaveTextContent(
"新規プロジェクト",
);
expect(screen.getByTestId("workspace-label")).toHaveTextContent("ワークスペース");
expect(screen.getByTestId("new-project")).toHaveTextContent("新規プロジェクト");
// Check status messages
const status = screen.getByTestId("status");
@@ -156,6 +129,9 @@ describe("Landing page translations", () => {
expect(status).toHaveTextContent("接続済み");
expect(status).toHaveTextContent("サーバーに接続済み");
// Check account settings menu
expect(screen.getByText("アカウント設定")).toBeInTheDocument();
// Check time-related translations
const time = screen.getByTestId("time");
expect(time).toHaveTextContent("5 分前");
@@ -183,12 +159,12 @@ describe("Landing page translations", () => {
"STATUS$CONNECTED_TO_SERVER",
"TIME$MINUTES_AGO",
"TIME$HOURS_AGO",
"TIME$DAYS_AGO",
"TIME$DAYS_AGO"
];
// Check all keys and collect missing translations
const missingTranslationsMap = new Map<string, string[]>();
translationKeys.forEach((key) => {
translationKeys.forEach(key => {
const missing = checkTranslationExists(key);
if (missing.length > 0) {
missingTranslationsMap.set(key, missing);
@@ -198,11 +174,8 @@ describe("Landing page translations", () => {
// If any translations are missing, throw an error with all missing translations
if (missingTranslationsMap.size > 0) {
const errorMessage = Array.from(missingTranslationsMap.entries())
.map(
([key, langs]) =>
`\n- "${key}" is missing translations for: ${langs.join(", ")}`,
)
.join("");
.map(([key, langs]) => `\n- "${key}" is missing translations for: ${langs.join(', ')}`)
.join('');
throw new Error(`Missing translations:${errorMessage}`);
}
});
@@ -211,9 +184,7 @@ describe("Landing page translations", () => {
const duplicates = findDuplicateKeys(translations);
if (duplicates.length > 0) {
throw new Error(
`Found duplicate translation keys: ${duplicates.join(", ")}`,
);
throw new Error(`Found duplicate translation keys: ${duplicates.join(', ')}`);
}
});
});

View File

@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderWithProviders } from "test-utils";
import { MicroagentsModal } from "#/components/features/conversation-panel/microagents-modal";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import OpenHands from "#/api/open-hands";
import { AgentState } from "#/types/agent-state";
vi.mock("react-redux", async () => {
@@ -48,7 +48,7 @@ describe("MicroagentsModal - Refresh Button", () => {
vi.clearAllMocks();
// Setup default mock for getUserConversations
vi.spyOn(ConversationService, "getMicroagents").mockResolvedValue({
vi.spyOn(OpenHands, "getMicroagents").mockResolvedValue({
microagents: mockMicroagents,
});
});
@@ -73,7 +73,7 @@ describe("MicroagentsModal - Refresh Button", () => {
renderWithProviders(<MicroagentsModal {...defaultProps} />);
const refreshSpy = vi.spyOn(ConversationService, "getMicroagents");
const refreshSpy = vi.spyOn(OpenHands, "getMicroagents");
const refreshButton = screen.getByTestId("refresh-microagents");
await user.click(refreshButton);

View File

@@ -3,13 +3,13 @@ import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { screen } from "@testing-library/react";
import SettingsService from "#/settings-service/settings-service.api";
import OpenHands from "#/api/open-hands";
import { SettingsForm } from "#/components/shared/modals/settings/settings-form";
import { DEFAULT_SETTINGS } from "#/services/settings";
describe("SettingsForm", () => {
const onCloseMock = vi.fn();
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const RouteStub = createRoutesStub([
{

View File

@@ -0,0 +1,58 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { UploadImageInput } from "#/components/features/images/upload-image-input";
describe("UploadImageInput", () => {
const user = userEvent.setup();
const onUploadMock = vi.fn();
afterEach(() => {
vi.clearAllMocks();
});
it("should render an input", () => {
render(<UploadImageInput onUpload={onUploadMock} />);
expect(screen.getByTestId("upload-image-input")).toBeInTheDocument();
});
it("should call onUpload when a file is selected", async () => {
render(<UploadImageInput onUpload={onUploadMock} />);
const file = new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" });
const input = screen.getByTestId("upload-image-input");
await user.upload(input, file);
expect(onUploadMock).toHaveBeenNthCalledWith(1, [file]);
});
it("should call onUpload when multiple files are selected", async () => {
render(<UploadImageInput onUpload={onUploadMock} />);
const files = [
new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" }),
new File(["(⌐□_□)"], "chucknorris2.png", { type: "image/png" }),
];
const input = screen.getByTestId("upload-image-input");
await user.upload(input, files);
expect(onUploadMock).toHaveBeenNthCalledWith(1, files);
});
it("should render custom labels", () => {
const { rerender } = render(<UploadImageInput onUpload={onUploadMock} />);
expect(screen.getByTestId("default-label")).toBeInTheDocument();
function CustomLabel() {
return <span>Custom label</span>;
}
rerender(
<UploadImageInput onUpload={onUploadMock} label={<CustomLabel />} />,
);
expect(screen.getByText("Custom label")).toBeInTheDocument();
expect(screen.queryByTestId("default-label")).not.toBeInTheDocument();
});
});

View File

@@ -2,9 +2,8 @@ import { render, screen } from "@testing-library/react";
import { describe, expect, it, test, vi, afterEach, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
import { UserActions } from "#/components/features/sidebar/user-actions";
import { MemoryRouter } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactElement } from "react";
import { renderWithProviders } from "../../test-utils";
// Create mocks for all the hooks we need
const useIsAuthedMock = vi
@@ -37,21 +36,30 @@ describe("UserActions", () => {
const onClickAccountSettingsMock = vi.fn();
const onLogoutMock = vi.fn();
// Create a wrapper with MemoryRouter and renderWithProviders
const renderWithRouter = (ui: ReactElement) => {
return renderWithProviders(<MemoryRouter>{ui}</MemoryRouter>);
// Create a wrapper with QueryClientProvider
const renderWithQueryClient = (ui: ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return render(ui, {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
});
};
beforeEach(() => {
// Reset all mocks to default values before each test
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
providers: [{ id: "github", name: "GitHub" }],
});
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
});
afterEach(() => {
@@ -61,14 +69,36 @@ describe("UserActions", () => {
});
it("should render", () => {
renderWithRouter(<UserActions onLogout={onLogoutMock} />);
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
});
it("should toggle the user menu when the user avatar is clicked", async () => {
renderWithQueryClient(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
await user.click(userAvatar);
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
});
it("should call onLogout and close the menu when the logout option is clicked", async () => {
renderWithRouter(
renderWithQueryClient(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
@@ -82,21 +112,19 @@ describe("UserActions", () => {
await user.click(logoutOption);
expect(onLogoutMock).toHaveBeenCalledOnce();
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
});
it("should NOT show context menu when user is not authenticated and avatar is clicked", async () => {
// Set isAuthed to false for this test
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
providers: [{ id: "github", name: "GitHub" }],
});
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
renderWithRouter(<UserActions onLogout={onLogoutMock} />);
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
@@ -108,7 +136,7 @@ describe("UserActions", () => {
});
it("should show context menu even when user has no avatar_url", async () => {
renderWithRouter(
renderWithQueryClient(
<UserActions onLogout={onLogoutMock} user={{ avatar_url: "" }} />,
);
@@ -125,15 +153,10 @@ describe("UserActions", () => {
// Set isAuthed to false for this test
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
providers: [{ id: "github", name: "GitHub" }],
});
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
renderWithRouter(<UserActions onLogout={onLogoutMock} />);
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
@@ -144,24 +167,17 @@ describe("UserActions", () => {
).not.toBeInTheDocument();
// Logout option should NOT be accessible when user is not authenticated
expect(
screen.queryByText("ACCOUNT_SETTINGS$LOGOUT"),
).not.toBeInTheDocument();
expect(screen.queryByText("ACCOUNT_SETTINGS$LOGOUT")).not.toBeInTheDocument();
});
it("should handle user prop changing from undefined to defined", async () => {
// Start with no authentication
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
providers: [{ id: "github", name: "GitHub" }],
});
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
const { unmount } = renderWithRouter(
const { rerender } = renderWithQueryClient(
<UserActions onLogout={onLogoutMock} />,
);
@@ -172,36 +188,37 @@ describe("UserActions", () => {
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
// Unmount the first component
unmount();
// Set authentication to true for the new render
// Set authentication to true for the rerender
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
// Ensure config and providers are set correctly
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
providers: [{ id: "github", name: "GitHub" }],
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
// Add user prop and create a new QueryClient to ensure fresh state
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
// Render a new component with user prop and authentication
renderWithRouter(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
rerender(
<QueryClientProvider client={queryClient}>
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>
</QueryClientProvider>,
);
// Component should render correctly
// Component should still render correctly
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
// Menu should now work with user defined and authenticated
userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
@@ -210,15 +227,10 @@ describe("UserActions", () => {
it("should handle user prop changing from defined to undefined", async () => {
// Start with authentication and providers
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
providers: [{ id: "github", name: "GitHub" }],
});
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
const { rerender } = renderWithRouter(
const { rerender } = renderWithQueryClient(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
@@ -235,19 +247,14 @@ describe("UserActions", () => {
// Set authentication to false for the rerender
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
providers: [{ id: "github", name: "GitHub" }],
});
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
// Remove user prop - menu should disappear because user is no longer authenticated
rerender(
<MemoryRouter>
<QueryClientProvider client={new QueryClient()}>
<UserActions onLogout={onLogoutMock} />
</MemoryRouter>,
</QueryClientProvider>,
);
// Context menu should NOT be visible when user becomes unauthenticated
@@ -256,23 +263,16 @@ describe("UserActions", () => {
).not.toBeInTheDocument();
// Logout option should not be accessible
expect(
screen.queryByText("ACCOUNT_SETTINGS$LOGOUT"),
).not.toBeInTheDocument();
expect(screen.queryByText("ACCOUNT_SETTINGS$LOGOUT")).not.toBeInTheDocument();
});
it("should work with loading state and user provided", async () => {
// Ensure authentication and providers are set correctly
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
providers: [{ id: "github", name: "GitHub" }],
});
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
renderWithRouter(
renderWithQueryClient(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}

View File

@@ -1,12 +1,12 @@
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import SettingsService from "#/settings-service/settings-service.api";
import OpenHands from "#/api/open-hands";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
describe("useSaveSettings", () => {
it("should send an empty string for llm_api_key if an empty string is passed, otherwise undefined", async () => {
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const { result } = renderHook(() => useSaveSettings(), {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>

View File

@@ -3,15 +3,15 @@ import { describe, expect, it, vi } from "vitest";
import i18n from "../../src/i18n";
import { AccountSettingsContextMenu } from "../../src/components/features/context-menu/account-settings-context-menu";
import { renderWithProviders } from "../../test-utils";
import { MemoryRouter } from "react-router";
describe("Translations", () => {
it("should render translated text", () => {
i18n.changeLanguage("en");
renderWithProviders(
<MemoryRouter>
<AccountSettingsContextMenu onLogout={() => {}} onClose={() => {}} />
</MemoryRouter>,
<AccountSettingsContextMenu
onLogout={() => {}}
onClose={() => {}}
/>,
);
expect(
screen.getByTestId("account-settings-context-menu"),

View File

@@ -8,9 +8,8 @@ import {
import userEvent from "@testing-library/user-event";
import MainApp from "#/routes/root-layout";
import i18n from "#/i18n";
import OptionService from "#/api/option-service/option-service.api";
import * as CaptureConsent from "#/utils/handle-capture-consent";
import SettingsService from "#/settings-service/settings-service.api";
import OpenHands from "#/api/open-hands";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
describe("frontend/routes/_oh", () => {
@@ -63,8 +62,8 @@ describe("frontend/routes/_oh", () => {
// FIXME: This test fails when it shouldn't be, please investigate
it.skip("should render and capture the user's consent if oss mode", async () => {
const user = userEvent.setup();
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const handleCaptureConsentSpy = vi.spyOn(
CaptureConsent,
"handleCaptureConsent",
@@ -107,7 +106,7 @@ describe("frontend/routes/_oh", () => {
});
it("should not render the user consent form if saas mode", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test-id",
@@ -185,8 +184,8 @@ describe("frontend/routes/_oh", () => {
});
it("should render a you're in toast if it is a new user and in saas mode", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
"displaySuccessToast",

View File

@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import AppSettingsScreen from "#/routes/app-settings";
import SettingsService from "#/settings-service/settings-service.api";
import OpenHands from "#/api/open-hands";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { AvailableLanguages } from "#/i18n";
import * as CaptureConsent from "#/utils/handle-capture-consent";
@@ -25,7 +25,7 @@ describe("Content", () => {
});
it("should render the correct default values", async () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
language: "no",
@@ -65,8 +65,8 @@ describe("Form submission", () => {
});
it("should submit the form with the correct values", async () => {
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderAppSettingsScreen();
@@ -106,7 +106,7 @@ describe("Form submission", () => {
});
it("should only enable the submit button when there are changes", async () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderAppSettingsScreen();
@@ -146,7 +146,7 @@ describe("Form submission", () => {
});
it("should call handleCaptureConsents with true when the analytics switch is toggled", async () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const handleCaptureConsentsSpy = vi.spyOn(
@@ -168,7 +168,7 @@ describe("Form submission", () => {
});
it("should call handleCaptureConsents with false when the analytics switch is toggled", async () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
user_consents_to_analytics: true,
@@ -215,8 +215,8 @@ describe("Form submission", () => {
});
it("should disable the button after submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderAppSettingsScreen();
@@ -240,8 +240,8 @@ describe("Form submission", () => {
describe("Status toasts", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displaySuccessToastSpy = vi.spyOn(
@@ -265,8 +265,8 @@ describe("Status toasts", () => {
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");

View File

@@ -6,11 +6,9 @@ import userEvent from "@testing-library/user-event";
import i18next from "i18next";
import { I18nextProvider } from "react-i18next";
import GitSettingsScreen from "#/routes/git-settings";
import SettingsService from "#/settings-service/settings-service.api";
import OptionService from "#/api/option-service/option-service.api";
import AuthService from "#/api/auth-service/auth-service.api";
import OpenHands from "#/api/open-hands";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { GetConfigResponse } from "#/api/option-service/option.types";
import { GetConfigResponse } from "#/api/open-hands.types";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
import { SecretsService } from "#/api/secrets-service";
@@ -110,7 +108,7 @@ describe("Content", () => {
});
it("should render the inputs if OSS mode", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
const { rerender } = renderGitSettingsScreen();
@@ -153,8 +151,8 @@ describe("Content", () => {
});
it("should set '<hidden>' placeholder and indicator if the GitHub token is set", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
getSettingsSpy.mockResolvedValue({
@@ -228,7 +226,7 @@ describe("Content", () => {
});
it("should render the 'Configure GitHub Repositories' button if SaaS mode and app slug exists", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
const { rerender } = renderGitSettingsScreen();
@@ -272,7 +270,7 @@ describe("Form submission", () => {
it("should save the GitHub token", async () => {
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
saveProvidersSpy.mockImplementation(() => Promise.resolve(true));
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
@@ -293,7 +291,7 @@ describe("Form submission", () => {
it("should save GitLab tokens", async () => {
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
saveProvidersSpy.mockImplementation(() => Promise.resolve(true));
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
@@ -314,7 +312,7 @@ describe("Form submission", () => {
it("should save the Bitbucket token", async () => {
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
saveProvidersSpy.mockImplementation(() => Promise.resolve(true));
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
@@ -333,7 +331,7 @@ describe("Form submission", () => {
});
it("should disable the button if there is no input", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
@@ -359,8 +357,8 @@ describe("Form submission", () => {
});
it("should enable a disconnect tokens button if there is at least one token set", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
getSettingsSpy.mockResolvedValue({
@@ -393,9 +391,9 @@ describe("Form submission", () => {
});
it("should call logout when pressing the disconnect tokens button", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const logoutSpy = vi.spyOn(AuthService, "logout");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const logoutSpy = vi.spyOn(OpenHands, "logout");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
getSettingsSpy.mockResolvedValue({
@@ -420,7 +418,7 @@ describe("Form submission", () => {
// flaky test
it.skip("should disable the button when submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(SecretsService, "addGitProvider");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
@@ -444,7 +442,7 @@ describe("Form submission", () => {
it("should disable the button after submitting changes", async () => {
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
@@ -478,7 +476,7 @@ describe("Form submission", () => {
describe("Status toasts", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displaySuccessToastSpy = vi.spyOn(
@@ -501,7 +499,7 @@ describe("Status toasts", () => {
it("should call displayErrorToast when the settings fail to save", async () => {
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");

View File

@@ -7,9 +7,7 @@ import { Provider } from "react-redux";
import { createAxiosNotFoundErrorObject, setupStore } from "test-utils";
import HomeScreen from "#/routes/home";
import { GitRepository } from "#/types/git";
import SettingsService from "#/settings-service/settings-service.api";
import GitService from "#/api/git-service/git-service.api";
import OptionService from "#/api/option-service/option-service.api";
import OpenHands from "#/api/open-hands";
import MainApp from "#/routes/root-layout";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
@@ -93,12 +91,12 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
describe("HomeScreen", () => {
beforeEach(() => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: "fake-token",
gitlab: "fake-token",
github: null,
gitlab: null,
},
});
});
@@ -120,144 +118,27 @@ describe("HomeScreen", () => {
it("should have responsive layout for mobile and desktop screens", async () => {
renderHomeScreen();
const homeScreenNewConversationSection = screen.getByTestId(
"home-screen-new-conversation-section",
);
expect(homeScreenNewConversationSection).toHaveClass(
"flex",
"flex-col",
"md:flex-row",
);
const homeScreenRecentConversationsSection = screen.getByTestId(
"home-screen-recent-conversations-section",
);
expect(homeScreenRecentConversationsSection).toHaveClass(
"flex",
"flex-col",
"md:flex-row",
);
const mainContainer = screen
.getByTestId("home-screen")
.querySelector("main");
expect(mainContainer).toHaveClass("flex", "flex-col", "lg:flex-row");
});
it("should filter the suggested tasks based on the selected repository", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
// Mock the repository branches API call
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
],
has_next_page: false,
current_page: 1,
per_page: 30,
total_count: 2,
});
renderHomeScreen();
const taskSuggestions = await screen.findByTestId("task-suggestions");
// Initially, all tasks should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
within(taskSuggestions).getByText("octocat/earth");
});
// Select a repository using the helper function
await selectRepository("octocat/hello-world");
// After selecting a repository, only tasks related to that repository should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
expect(
within(taskSuggestions).queryByText("octocat/earth"),
).not.toBeInTheDocument();
});
});
it("should filter tasks when different repositories are selected", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
// Mock the repository branches API call
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
],
has_next_page: false,
current_page: 1,
per_page: 30,
total_count: 2,
});
renderHomeScreen();
const taskSuggestions = await screen.findByTestId("task-suggestions");
// Initially, all tasks should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
within(taskSuggestions).getByText("octocat/earth");
});
// Select the first repository
await selectRepository("octocat/hello-world");
// After selecting first repository, only tasks related to that repository should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
expect(
within(taskSuggestions).queryByText("octocat/earth"),
).not.toBeInTheDocument();
});
// Now select the second repository
await selectRepository("octocat/earth");
// After selecting second repository, only tasks related to that repository should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/earth");
expect(
within(taskSuggestions).queryByText("octocat/hello-world"),
).not.toBeInTheDocument();
});
});
// TODO: Fix this test
it.skip("should filter and reset the suggested tasks based on repository selection", async () => {});
describe("launch buttons", () => {
const setupLaunchButtons = async () => {
let headerLaunchButton = screen.getByTestId(
"launch-new-conversation-button",
);
let headerLaunchButton = screen.getByTestId("header-launch-button");
let repoLaunchButton = await screen.findByTestId("repo-launch-button");
let tasksLaunchButtons =
await screen.findAllByTestId("task-launch-button");
// Mock the repository branches API call
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
],
has_next_page: false,
current_page: 1,
per_page: 30,
total_count: 2,
});
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
// Select a repository to enable the repo launch button
await selectRepository("octocat/hello-world");
@@ -271,7 +152,8 @@ describe("HomeScreen", () => {
});
});
headerLaunchButton = screen.getByTestId("launch-new-conversation-button");
// Get fresh references to the buttons
headerLaunchButton = screen.getByTestId("header-launch-button");
repoLaunchButton = screen.getByTestId("repo-launch-button");
tasksLaunchButtons = await screen.findAllByTestId("task-launch-button");
@@ -284,7 +166,7 @@ describe("HomeScreen", () => {
beforeEach(() => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
@@ -353,6 +235,16 @@ describe("HomeScreen", () => {
});
});
});
it("should hide the suggested tasks section if not authed with git(hub|lab)", async () => {
renderHomeScreen();
const taskSuggestions = screen.queryByTestId("task-suggestions");
const repoConnector = screen.getByTestId("repo-connector");
expect(taskSuggestions).not.toBeInTheDocument();
expect(repoConnector).toBeInTheDocument();
});
});
describe("Settings 404", () => {
@@ -360,8 +252,8 @@ describe("Settings 404", () => {
vi.resetAllMocks();
});
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
it("should open the settings modal if GET /settings fails with a 404", async () => {
const error = createAxiosNotFoundErrorObject();
@@ -373,10 +265,11 @@ describe("Settings 404", () => {
expect(settingsModal).toBeInTheDocument();
});
it("should have the correct advanced settings link that opens in a new window", async () => {
it("should navigate to the settings screen when clicking the advanced settings button", async () => {
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
const user = userEvent.setup();
renderHomeScreen();
const settingsScreen = screen.queryByTestId("settings-screen");
@@ -385,16 +278,16 @@ describe("Settings 404", () => {
const settingsModal = await screen.findByTestId("ai-config-modal");
expect(settingsModal).toBeInTheDocument();
const advancedSettingsLink = await screen.findByTestId(
const advancedSettingsButton = await screen.findByTestId(
"advanced-settings-link",
);
await user.click(advancedSettingsButton);
// The advanced settings link should be an anchor tag that opens in a new window
const linkElement = advancedSettingsLink.querySelector("a");
expect(linkElement).toBeInTheDocument();
expect(linkElement).toHaveAttribute("href", "/settings");
expect(linkElement).toHaveAttribute("target", "_blank");
expect(linkElement).toHaveAttribute("rel", "noreferrer noopener");
const settingsScreenAfter = await screen.findByTestId("settings-screen");
expect(settingsScreenAfter).toBeInTheDocument();
const settingsModalAfter = screen.queryByTestId("ai-config-modal");
expect(settingsModalAfter).not.toBeInTheDocument();
});
it("should not open the settings modal if GET /settings fails but is SaaS mode", async () => {
@@ -419,8 +312,8 @@ describe("Settings 404", () => {
});
describe("Setup Payment modal", () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
it("should only render if SaaS mode and is new user", async () => {
// @ts-expect-error - we only need the APP_MODE for this test

View File

@@ -3,27 +3,13 @@ import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import LlmSettingsScreen from "#/routes/llm-settings";
import SettingsService from "#/settings-service/settings-service.api";
import OptionService from "#/api/option-service/option-service.api";
import OpenHands from "#/api/open-hands";
import {
MOCK_DEFAULT_USER_SETTINGS,
resetTestHandlersMockSettings,
} from "#/mocks/handlers";
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
import BillingService from "#/api/billing-service/billing-service.api";
// Mock react-router hooks
const mockUseSearchParams = vi.fn();
vi.mock("react-router", () => ({
useSearchParams: () => mockUseSearchParams(),
}));
// Mock useIsAuthed hook
const mockUseIsAuthed = vi.fn();
vi.mock("#/hooks/query/use-is-authed", () => ({
useIsAuthed: () => mockUseIsAuthed(),
}));
const renderLlmSettingsScreen = () =>
render(<LlmSettingsScreen />, {
@@ -37,17 +23,6 @@ const renderLlmSettingsScreen = () =>
beforeEach(() => {
vi.resetAllMocks();
resetTestHandlersMockSettings();
// Default mock for useSearchParams - returns empty params
mockUseSearchParams.mockReturnValue([
{
get: () => null,
},
vi.fn(),
]);
// Default mock for useIsAuthed - returns authenticated by default
mockUseIsAuthed.mockReturnValue({ data: true, isLoading: false });
});
describe("Content", () => {
@@ -81,7 +56,7 @@ describe("Content", () => {
});
it("should render the existing settings values", async () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
@@ -109,9 +84,7 @@ describe("Content", () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const confirmation = screen.getByTestId(
"enable-confirmation-mode-switch",
);
const confirmation = screen.getByTestId("enable-confirmation-mode-switch");
// Initially confirmation mode is false, so security analyzer should not be visible
expect(confirmation).not.toBeChecked();
@@ -212,7 +185,7 @@ describe("Content", () => {
});
it("should render existing advanced settings correctly", async () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
@@ -257,7 +230,7 @@ describe("Content", () => {
describe("Form submission", () => {
it("should submit the basic form with the correct values", async () => {
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
@@ -293,7 +266,7 @@ describe("Form submission", () => {
});
it("should submit the advanced form with the correct values", async () => {
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
@@ -337,9 +310,7 @@ describe("Form submission", () => {
// select security analyzer
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
await userEvent.click(securityAnalyzer);
const securityAnalyzerOption = screen.getByText(
"SETTINGS$SECURITY_ANALYZER_NONE",
);
const securityAnalyzerOption = screen.getByText("SETTINGS$SECURITY_ANALYZER_NONE");
await userEvent.click(securityAnalyzerOption);
const submitButton = screen.getByTestId("submit-button");
@@ -358,7 +329,7 @@ describe("Form submission", () => {
});
it("should disable the button if there are no changes in the basic form", async () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
@@ -401,7 +372,7 @@ describe("Form submission", () => {
});
it("should disable the button if there are no changes in the advanced form", async () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
@@ -421,14 +392,10 @@ describe("Form submission", () => {
const baseUrl = await screen.findByTestId("base-url-input");
const apiKey = await screen.findByTestId("llm-api-key-input");
const agent = await screen.findByTestId("agent-input");
const condensor = await screen.findByTestId(
"enable-memory-condenser-switch",
);
const condensor = await screen.findByTestId("enable-memory-condenser-switch");
// Confirmation mode switch is now in basic settings, always visible
const confirmation = await screen.findByTestId(
"enable-confirmation-mode-switch",
);
const confirmation = await screen.findByTestId("enable-confirmation-mode-switch");
// enter custom model
await userEvent.type(model, "-mini");
@@ -501,13 +468,9 @@ describe("Form submission", () => {
expect(submitButton).toBeDisabled();
// select security analyzer
const securityAnalyzer = await screen.findByTestId(
"security-analyzer-input",
);
const securityAnalyzer = await screen.findByTestId("security-analyzer-input");
await userEvent.click(securityAnalyzer);
const securityAnalyzerOption = screen.getByText(
"SETTINGS$SECURITY_ANALYZER_NONE",
);
const securityAnalyzerOption = screen.getByText("SETTINGS$SECURITY_ANALYZER_NONE");
await userEvent.click(securityAnalyzerOption);
expect(securityAnalyzer).toHaveValue("SETTINGS$SECURITY_ANALYZER_NONE");
@@ -515,13 +478,9 @@ describe("Form submission", () => {
// revert back to original value
await userEvent.click(securityAnalyzer);
const originalSecurityAnalyzerOption = screen.getByText(
"SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT",
);
const originalSecurityAnalyzerOption = screen.getByText("SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT");
await userEvent.click(originalSecurityAnalyzerOption);
expect(securityAnalyzer).toHaveValue(
"SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT",
);
expect(securityAnalyzer).toHaveValue("SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT");
expect(submitButton).toBeDisabled();
});
@@ -553,7 +512,7 @@ describe("Form submission", () => {
// flaky test
it.skip("should disable the button when submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
@@ -580,7 +539,7 @@ describe("Form submission", () => {
});
it("should clear advanced settings when saving basic settings", async () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
@@ -588,7 +547,7 @@ describe("Form submission", () => {
llm_api_key_set: true,
confirmation_mode: true,
});
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
@@ -624,7 +583,7 @@ describe("Form submission", () => {
describe("Status toasts", () => {
describe("Basic form", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
@@ -645,7 +604,7 @@ describe("Status toasts", () => {
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
@@ -667,7 +626,7 @@ describe("Status toasts", () => {
describe("Advanced form", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
@@ -693,7 +652,7 @@ describe("Status toasts", () => {
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
@@ -720,401 +679,58 @@ describe("Status toasts", () => {
});
describe("SaaS mode", () => {
describe("SaaS subscription", () => {
// Common mock configurations
const MOCK_SAAS_CONFIG = {
APP_MODE: "saas" as const,
GITHUB_CLIENT_ID: "fake-github-client-id",
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
};
const MOCK_ACTIVE_SUBSCRIPTION = {
start_at: "2024-01-01",
end_at: "2024-12-31",
created_at: "2024-01-01",
};
it("should show upgrade banner and prevent all interactions for unsubscribed SaaS users", async () => {
// Mock SaaS mode without subscription
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
// Mock subscription access to return null (no subscription)
const getSubscriptionAccessSpy = vi.spyOn(
BillingService,
"getSubscriptionAccess",
);
getSubscriptionAccessSpy.mockResolvedValue(null);
// Mock saveSettings to ensure it's not called
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Should show upgrade banner
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
// Should have a clickable upgrade button
const upgradeButton = screen.getByRole("button", { name: /upgrade/i });
expect(upgradeButton).toBeInTheDocument();
expect(upgradeButton).not.toBeDisabled();
// Form should be disabled
const form = screen.getByTestId("llm-settings-form-basic");
expect(form).toHaveAttribute("aria-disabled", "true");
// All form inputs should be disabled or non-interactive
const providerInput = screen.getByTestId("llm-provider-input");
const modelInput = screen.getByTestId("llm-model-input");
const apiKeyInput = screen.getByTestId("llm-api-key-input");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
const confirmationModeSwitch = screen.getByTestId(
"enable-confirmation-mode-switch",
);
const submitButton = screen.getByTestId("submit-button");
// Inputs should be disabled
expect(providerInput).toBeDisabled();
expect(modelInput).toBeDisabled();
expect(apiKeyInput).toBeDisabled();
expect(advancedSwitch).toBeDisabled();
expect(confirmationModeSwitch).toBeDisabled();
expect(submitButton).toBeDisabled();
// Try to interact with inputs - they should not respond
await userEvent.click(providerInput);
await userEvent.type(apiKeyInput, "test-key");
// Values should not change
expect(apiKeyInput).toHaveValue("");
// Try to submit form - should not call API
await userEvent.click(submitButton);
expect(saveSettingsSpy).not.toHaveBeenCalled();
it("should not render the runtime settings input in oss mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return mode
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
});
it("should call subscription checkout API when upgrade button is clicked", async () => {
// Mock SaaS mode without subscription
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Mock subscription access to return null (no subscription)
const getSubscriptionAccessSpy = vi.spyOn(
BillingService,
"getSubscriptionAccess",
);
getSubscriptionAccessSpy.mockResolvedValue(null);
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
await screen.findByTestId("llm-settings-form-advanced");
// Mock the subscription checkout API call
const createSubscriptionCheckoutSessionSpy = vi.spyOn(
BillingService,
"createSubscriptionCheckoutSession",
);
createSubscriptionCheckoutSessionSpy.mockResolvedValue({});
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
expect(runtimeSettingsInput).not.toBeInTheDocument();
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Click the upgrade button
const upgradeButton = screen.getByRole("button", { name: /upgrade/i });
await userEvent.click(upgradeButton);
// Should call the subscription checkout API
expect(createSubscriptionCheckoutSessionSpy).toHaveBeenCalled();
it("should render the runtime settings input in saas mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return mode
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
it("should disable upgrade button for unauthenticated users in SaaS mode", async () => {
// Mock SaaS mode without subscription
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Mock subscription access to return null (no subscription)
const getSubscriptionAccessSpy = vi.spyOn(
BillingService,
"getSubscriptionAccess",
);
getSubscriptionAccessSpy.mockResolvedValue(null);
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
await screen.findByTestId("llm-settings-form-advanced");
// Mock subscription checkout API
const createSubscriptionCheckoutSessionSpy = vi.spyOn(
BillingService,
"createSubscriptionCheckoutSession",
);
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
expect(runtimeSettingsInput).toBeInTheDocument();
});
// Mock authentication to return false (unauthenticated) from the start
mockUseIsAuthed.mockReturnValue({ data: false, isLoading: false });
// Mock settings to return default settings even when unauthenticated
// This is necessary because the useSettings hook is disabled when user is not authenticated
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderLlmSettingsScreen();
// Wait for either the settings screen or skeleton to appear
await waitFor(() => {
const settingsScreen = screen.queryByTestId("llm-settings-screen");
const skeleton = screen.queryByTestId("app-settings-skeleton");
expect(settingsScreen || skeleton).toBeInTheDocument();
});
// If we get the skeleton, the test scenario isn't valid - skip the rest
if (screen.queryByTestId("app-settings-skeleton")) {
// For unauthenticated users, the settings don't load, so no upgrade banner is shown
// This is the expected behavior - unauthenticated users see a skeleton loading state
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
return;
}
await screen.findByTestId("llm-settings-screen");
// Should show upgrade banner
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
// Upgrade button should be disabled for unauthenticated users
const upgradeButton = screen.getByRole("button", { name: /upgrade/i });
expect(upgradeButton).toBeInTheDocument();
expect(upgradeButton).toBeDisabled();
// Clicking disabled button should not call the API
await userEvent.click(upgradeButton);
expect(createSubscriptionCheckoutSessionSpy).not.toHaveBeenCalled();
it("should always render the runtime settings input as disabled", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return mode
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
it("should not show upgrade banner and allow form interaction for subscribed SaaS users", async () => {
// Mock SaaS mode with subscription
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Mock subscription access to return active subscription
const getSubscriptionAccessSpy = vi.spyOn(
BillingService,
"getSubscriptionAccess",
);
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
await screen.findByTestId("llm-settings-form-advanced");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Wait for subscription data to load
await waitFor(() => {
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
});
// Should NOT show upgrade banner
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
// Form should NOT be disabled
const form = screen.getByTestId("llm-settings-form-basic");
expect(form).not.toHaveAttribute("aria-disabled", "true");
});
it("should not call save settings API when making changes in disabled form for unsubscribed users", async () => {
// Mock SaaS mode without subscription
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
// Mock subscription access to return null (no subscription)
const getSubscriptionAccessSpy = vi.spyOn(
BillingService,
"getSubscriptionAccess",
);
getSubscriptionAccessSpy.mockResolvedValue(null);
// Mock saveSettings to track calls
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Verify that form elements are disabled for unsubscribed users
const confirmationModeSwitch = screen.getByTestId(
"enable-confirmation-mode-switch",
);
const submitButton = screen.getByTestId("submit-button");
expect(confirmationModeSwitch).not.toBeChecked();
expect(confirmationModeSwitch).toBeDisabled();
expect(submitButton).toBeDisabled();
// Try to click the disabled confirmation mode switch - it should not change state
await userEvent.click(confirmationModeSwitch);
expect(confirmationModeSwitch).not.toBeChecked(); // Should remain unchecked
// Try to submit the form - button should remain disabled
await userEvent.click(submitButton);
// Should NOT call save settings API for unsubscribed users
expect(saveSettingsSpy).not.toHaveBeenCalled();
});
it("should show backdrop overlay for unsubscribed users", async () => {
// Mock SaaS mode without subscription
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
// Mock subscription access to return null (no subscription)
const getSubscriptionAccessSpy = vi.spyOn(
BillingService,
"getSubscriptionAccess",
);
getSubscriptionAccessSpy.mockResolvedValue(null);
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Wait for subscription data to load
await waitFor(() => {
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
});
// Should show upgrade banner
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
// Should show backdrop overlay
const backdrop = screen.getByTestId("settings-backdrop");
expect(backdrop).toBeInTheDocument();
});
it("should not show backdrop overlay for subscribed users", async () => {
// Mock SaaS mode with subscription
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
// Mock subscription access to return active subscription
const getSubscriptionAccessSpy = vi.spyOn(
BillingService,
"getSubscriptionAccess",
);
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Wait for subscription data to load
await waitFor(() => {
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
});
// Should NOT show backdrop overlay
expect(screen.queryByTestId("settings-backdrop")).not.toBeInTheDocument();
});
it("should display success toast when redirected back with ?checkout=success parameter", async () => {
// Mock SaaS mode
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
// Mock subscription access
const getSubscriptionAccessSpy = vi.spyOn(
BillingService,
"getSubscriptionAccess",
);
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
// Mock toast handler
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
"displaySuccessToast",
);
// Mock URL search params with ?checkout=success
mockUseSearchParams.mockReturnValue([
{
get: (param: string) => (param === "checkout" ? "success" : null),
},
vi.fn(),
]);
// Render component with checkout=success parameter
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Verify success toast is displayed with correct message
expect(displaySuccessToastSpy).toHaveBeenCalledWith(
"SUBSCRIPTION$SUCCESS",
);
});
it("should display error toast when redirected back with ?checkout=cancel parameter", async () => {
// Mock SaaS mode
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
// Mock subscription access
const getSubscriptionAccessSpy = vi.spyOn(
BillingService,
"getSubscriptionAccess",
);
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
// Mock toast handler
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
// Mock URL search params with ?checkout=cancel
mockUseSearchParams.mockReturnValue([
{
get: (param: string) => (param === "checkout" ? "cancel" : null),
},
vi.fn(),
]);
// Render component with checkout=cancel parameter
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Verify error toast is displayed with correct message
expect(displayErrorToastSpy).toHaveBeenCalledWith("SUBSCRIPTION$FAILURE");
});
it("should show upgrade banner when subscription is expired or disabled", async () => {
// Mock SaaS mode
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
// Mock subscription access to return null (expired/disabled subscriptions return null from backend)
// The backend only returns active subscriptions within their validity period
const getSubscriptionAccessSpy = vi.spyOn(
BillingService,
"getSubscriptionAccess",
);
getSubscriptionAccessSpy.mockResolvedValue(null);
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Wait for subscription data to load
await waitFor(() => {
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
});
// Should show upgrade banner for expired/disabled subscriptions (when API returns null)
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
// Form should be disabled
const form = screen.getByTestId("llm-settings-form-basic");
expect(form).toHaveAttribute("aria-disabled", "true");
// All form inputs should be disabled
const providerInput = screen.getByTestId("llm-provider-input");
const modelInput = screen.getByTestId("llm-model-input");
const apiKeyInput = screen.getByTestId("llm-api-key-input");
const confirmationModeSwitch = screen.getByTestId(
"enable-confirmation-mode-switch",
);
expect(providerInput).toBeDisabled();
expect(modelInput).toBeDisabled();
expect(apiKeyInput).toBeDisabled();
expect(confirmationModeSwitch).toBeDisabled();
});
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
expect(runtimeSettingsInput).toBeInTheDocument();
expect(runtimeSettingsInput).toBeDisabled();
});
});

View File

@@ -6,8 +6,7 @@ import { createRoutesStub, Outlet } from "react-router";
import SecretsSettingsScreen from "#/routes/secrets-settings";
import { SecretsService } from "#/api/secrets-service";
import { GetSecretsResponse } from "#/api/secrets-service.types";
import SettingsService from "#/settings-service/settings-service.api";
import OptionService from "#/api/option-service/option-service.api";
import OpenHands from "#/api/open-hands";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
const MOCK_GET_SECRETS_RESPONSE: GetSecretsResponse["custom_secrets"] = [
@@ -54,7 +53,7 @@ const renderSecretsSettings = () =>
});
beforeEach(() => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return the config we need
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
@@ -68,8 +67,8 @@ describe("Content", () => {
});
it("should NOT render a button to connect with git if they havent already in oss", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
// @ts-expect-error - only return the config we need
getConfigSpy.mockResolvedValue({
@@ -87,21 +86,28 @@ describe("Content", () => {
expect(screen.queryByTestId("connect-git-button")).not.toBeInTheDocument();
});
it("should render add secret button in saas mode", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
it("should render a button to connect with git if they havent already in saas", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
// @ts-expect-error - only return the config we need
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {},
});
renderSecretsSettings();
// In SAAS mode, getSecrets is called and add secret button should be available
// In SAAS mode, getSecrets is still called because the user is authenticated
await waitFor(() => expect(getSecretsSpy).toHaveBeenCalled());
const button = await screen.findByTestId("add-secret-button");
expect(button).toBeInTheDocument();
expect(screen.queryByTestId("connect-git-button")).not.toBeInTheDocument();
await waitFor(() =>
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument(),
);
const button = await screen.findByTestId("connect-git-button");
expect(button).toHaveAttribute("href", "/settings/integrations");
});
it("should render an empty table when there are no existing secrets", async () => {
@@ -477,9 +483,7 @@ describe("Secret actions", () => {
// make POST request
expect(createSecretSpy).not.toHaveBeenCalled();
expect(
screen.queryByText("SECRETS$SECRET_ALREADY_EXISTS"),
).toBeInTheDocument();
expect(screen.queryByText("SECRETS$SECRET_ALREADY_EXISTS")).toBeInTheDocument();
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "My_Custom_Secret");
@@ -563,9 +567,7 @@ describe("Secret actions", () => {
// make POST request
expect(createSecretSpy).not.toHaveBeenCalled();
expect(
screen.queryByText("SECRETS$SECRET_ALREADY_EXISTS"),
).toBeInTheDocument();
expect(screen.queryByText("SECRETS$SECRET_ALREADY_EXISTS")).toBeInTheDocument();
expect(nameInput).toHaveValue(MOCK_GET_SECRETS_RESPONSE[0].name);
expect(valueInput).toHaveValue("my-custom-secret-value");

View File

@@ -3,14 +3,14 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoutesStub } from "react-router";
import { renderWithProviders } from "test-utils";
import OpenHands from "#/api/open-hands";
import SettingsScreen from "#/routes/settings";
import { PaymentForm } from "#/components/features/payment/payment-form";
import * as useSettingsModule from "#/hooks/query/use-settings";
// Mock the useSettings hook
vi.mock("#/hooks/query/use-settings", async () => {
const actual = await vi.importActual<
typeof import("#/hooks/query/use-settings")
>("#/hooks/query/use-settings");
const actual = await vi.importActual<typeof import("#/hooks/query/use-settings")>("#/hooks/query/use-settings");
return {
...actual,
useSettings: vi.fn().mockReturnValue({
@@ -24,23 +24,21 @@ vi.mock("#/hooks/query/use-settings", async () => {
// Mock the i18next hook
vi.mock("react-i18next", async () => {
const actual =
await vi.importActual<typeof import("react-i18next")>("react-i18next");
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
SETTINGS$NAV_INTEGRATIONS: "Integrations",
SETTINGS$NAV_APPLICATION: "Application",
SETTINGS$NAV_CREDITS: "Credits",
SETTINGS$NAV_BILLING: "Billing",
SETTINGS$NAV_API_KEYS: "API Keys",
SETTINGS$NAV_LLM: "LLM",
SETTINGS$NAV_USER: "User",
SETTINGS$NAV_SECRETS: "Secrets",
SETTINGS$NAV_MCP: "MCP",
SETTINGS$TITLE: "Settings",
"SETTINGS$NAV_INTEGRATIONS": "Integrations",
"SETTINGS$NAV_APPLICATION": "Application",
"SETTINGS$NAV_CREDITS": "Credits",
"SETTINGS$NAV_API_KEYS": "API Keys",
"SETTINGS$NAV_LLM": "LLM",
"SETTINGS$NAV_USER": "User",
"SETTINGS$NAV_SECRETS": "Secrets",
"SETTINGS$NAV_MCP": "MCP",
"SETTINGS$TITLE": "Settings"
};
return translations[key] || key;
},
@@ -107,16 +105,16 @@ describe("Settings Billing", () => {
vi.clearAllMocks();
});
it("should not render the billing tab if OSS mode", async () => {
it("should not render the credits tab if OSS mode", async () => {
// OSS mode is set by default in beforeEach
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
const credits = within(navbar).queryByText("Billing");
const credits = within(navbar).queryByText("Credits");
expect(credits).not.toBeInTheDocument();
});
it("should render the billing tab if SaaS mode and billing is enabled", async () => {
it("should render the credits tab if SaaS mode and billing is enabled", async () => {
mockUseConfig.mockReturnValue({
data: {
APP_MODE: "saas",
@@ -136,10 +134,10 @@ describe("Settings Billing", () => {
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
within(navbar).getByText("Billing");
within(navbar).getByText("Credits");
});
it("should render the billing settings if clicking the billing item", async () => {
it("should render the billing settings if clicking the credits item", async () => {
const user = userEvent.setup();
mockUseConfig.mockReturnValue({
data: {
@@ -160,7 +158,7 @@ describe("Settings Billing", () => {
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
const credits = within(navbar).getByText("Billing");
const credits = within(navbar).getByText("Credits");
await user.click(credits);
const billingSection = await screen.findByTestId("billing-settings");

View File

@@ -3,7 +3,7 @@ import { createRoutesStub } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { QueryClientProvider } from "@tanstack/react-query";
import SettingsScreen, { clientLoader } from "#/routes/settings";
import OptionService from "#/api/option-service/option-service.api";
import OpenHands from "#/api/open-hands";
// Mock the i18next hook
vi.mock("react-i18next", async () => {
@@ -93,7 +93,7 @@ describe("Settings Screen", () => {
it("should render the navbar", async () => {
const sectionsToInclude = ["llm", "integrations", "application", "secrets"];
const sectionsToExclude = ["api keys", "credits", "billing"];
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return app mode
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
@@ -129,15 +129,14 @@ describe("Settings Screen", () => {
mockQueryClient.setQueryData(["config"], saasConfig);
const sectionsToInclude = [
"llm", // LLM settings are now always shown in SaaS mode
"user",
"integrations",
"application",
"billing", // The nav item shows "billing" text and routes to /billing
"credits", // The nav item shows "credits" text but routes to /billing
"secrets",
"api keys",
];
const sectionsToExclude: string[] = []; // No sections are excluded in SaaS mode now
const sectionsToExclude = ["llm"];
renderSettingsScreen();
@@ -157,7 +156,7 @@ describe("Settings Screen", () => {
});
it("should not be able to access saas-only routes in oss mode", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return app mode
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",

View File

@@ -1,59 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import { useSuggestedTasks } from "../src/hooks/query/use-suggested-tasks";
import { useShouldShowUserFeatures } from "../src/hooks/use-should-show-user-features";
// Mock the dependencies
vi.mock("../src/hooks/use-should-show-user-features");
vi.mock("#/api/suggestions-service/suggestions-service.api", () => ({
SuggestionsService: {
getSuggestedTasks: vi.fn(),
},
}));
const mockUseShouldShowUserFeatures = vi.mocked(useShouldShowUserFeatures);
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
};
describe("useSuggestedTasks", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default to disabled
mockUseShouldShowUserFeatures.mockReturnValue(false);
});
it("should be disabled when useShouldShowUserFeatures returns false", () => {
mockUseShouldShowUserFeatures.mockReturnValue(false);
const { result } = renderHook(() => useSuggestedTasks(), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(false);
expect(result.current.isFetching).toBe(false);
});
it("should be enabled when useShouldShowUserFeatures returns true", () => {
mockUseShouldShowUserFeatures.mockReturnValue(true);
const { result } = renderHook(() => useSuggestedTasks(), {
wrapper: createWrapper(),
});
// When enabled, the query should be loading/fetching
expect(result.current.isLoading).toBe(true);
});
});

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { browserTab } from "#/utils/browser-tab";
// These tests exercise the browser-tab notification flasher behavior.
// Specifically we verify that when the document title changes externally
// while a notification is active, the flasher updates its internal
// baseline so it restores/toggles to the new title instead of an old one.
describe("browserTab notifications", () => {
const MESSAGE = "Agent ready";
const INITIAL = "Conversation 123 | OpenHands";
const RENAMED = "My renamed title | OpenHands";
beforeEach(() => {
vi.useFakeTimers();
// reset title for each test
document.title = INITIAL;
});
afterEach(() => {
browserTab.stopNotification();
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
it("updates baseline when title changes during an active notification and restores to the new title", () => {
// Start flashing
browserTab.startNotification(MESSAGE);
// Tick once: should switch to the message
vi.advanceTimersByTime(1000);
expect(document.title).toBe(MESSAGE);
// Simulate an external rename while flashing (e.g., user edits title)
document.title = RENAMED;
// Next tick: flasher observes the external change and updates baseline
vi.advanceTimersByTime(1000);
// On this tick, we toggle back to the message
expect(document.title).toBe(MESSAGE);
// Next tick should toggle to the updated baseline (renamed title)
vi.advanceTimersByTime(1000);
expect(document.title).toBe(RENAMED);
// Stop flashing: title should remain the updated baseline
browserTab.stopNotification();
expect(document.title).toBe(RENAMED);
});
});

View File

@@ -1,73 +1,18 @@
import { render, screen } from "@testing-library/react";
import { test, expect, describe, vi } from "vitest";
import { MemoryRouter } from "react-router";
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
import { renderWithProviders } from "../../test-utils";
import { ChatInput } from "#/components/features/chat/chat-input";
// Mock the translation function
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => {
// Return a mock translation for the test
const translations: Record<string, string> = {
CHAT$PLACEHOLDER: "What do you want to build?",
};
return translations[key] || key;
},
}),
};
});
// Mock the useActiveConversation hook
vi.mock("#/hooks/query/use-active-conversation", () => ({
useActiveConversation: () => ({
data: null,
}),
}));
// Mock React Router hooks
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...actual,
useNavigate: () => vi.fn(),
useParams: () => ({ conversationId: "test-conversation-id" }),
};
});
// Mock other hooks that might be used by the component
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => ({
providers: [],
}),
}));
vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
useConversationNameContextMenu: () => ({
isOpen: false,
contextMenuRef: { current: null },
handleContextMenu: vi.fn(),
handleClose: vi.fn(),
handleRename: vi.fn(),
handleDelete: vi.fn(),
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe("Check for hardcoded English strings", () => {
test("InteractiveChatBox should not have hardcoded English strings", () => {
const { container } = renderWithProviders(
<MemoryRouter>
<InteractiveChatBox
onSubmit={() => {}}
onStop={() => {}}
isWaitingForUserInput={false}
hasSubstantiveAgentActions={false}
optimisticUserMessage={false}
/>
</MemoryRouter>,
const { container } = render(
<InteractiveChatBox onSubmit={() => {}} onStop={() => {}} />,
);
// Get all text content
@@ -77,7 +22,7 @@ describe("Check for hardcoded English strings", () => {
const hardcodedStrings = [
"What do you want to build?",
"Launch from Scratch",
"Read this",
"Read this"
];
// Check each string
@@ -85,4 +30,9 @@ describe("Check for hardcoded English strings", () => {
expect(text).not.toContain(str);
});
});
test("ChatInput should use translation key for placeholder", () => {
render(<ChatInput onSubmit={() => {}} />);
screen.getByPlaceholderText("SUGGESTIONS$WHAT_TO_BUILD");
});
});

View File

@@ -0,0 +1,9 @@
import { test, expect } from "vitest";
import { formatMs } from "../../src/utils/format-ms";
test("formatMs", () => {
expect(formatMs(1000)).toBe("00:01");
expect(formatMs(1000 * 60)).toBe("01:00");
expect(formatMs(1000 * 60 * 2.5)).toBe("02:30");
expect(formatMs(1000 * 60 * 12)).toBe("12:00");
});

View File

@@ -1,5 +1,8 @@
import { expect, test } from "vitest";
import { SuggestedTask, SuggestedTaskGroup } from "#/utils/types";
import {
SuggestedTask,
SuggestedTaskGroup,
} from "#/components/features/home/tasks/task.types";
import { groupSuggestedTasks } from "#/utils/group-suggested-tasks";
const rawTasks: SuggestedTask[] = [

View File

@@ -0,0 +1,29 @@
import { ReactNode } from "react";
import { I18nextProvider } from "react-i18next";
const mockI18n = {
language: "ja",
t: (key: string) => {
const translations: Record<string, string> = {
"SUGGESTIONS$TODO_APP": "ToDoリストアプリを開発する",
"LANDING$BUILD_APP_BUTTON": "プルリクエストを表示するアプリを開発する",
"SUGGESTIONS$HACKER_NEWS": "Hacker Newsのトップ記事を表示するbashスクリプトを作成する",
"LANDING$TITLE": "一緒に開発を始めましょう!",
"OPEN_IN_VSCODE": "VS Codeで開く",
"INCREASE_TEST_COVERAGE": "テストカバレッジを向上",
"AUTO_MERGE_PRS": "PRを自動マージ",
"FIX_README": "READMEを修正",
"CLEAN_DEPENDENCIES": "依存関係を整理"
};
return translations[key] || key;
},
exists: () => true,
changeLanguage: () => new Promise(() => {}),
use: () => mockI18n,
};
export function I18nTestProvider({ children }: { children: ReactNode }) {
return (
<I18nextProvider i18n={mockI18n as any}>{children}</I18nextProvider>
);
}

View File

@@ -0,0 +1,20 @@
import { expect, test } from "vitest";
import { parseGithubUrl } from "../../src/utils/parse-github-url";
test("parseGithubUrl", () => {
expect(
parseGithubUrl("https://github.com/alexreardon/tiny-invariant"),
).toEqual(["alexreardon", "tiny-invariant"]);
expect(parseGithubUrl("https://github.com/All-Hands-AI/OpenHands")).toEqual([
"All-Hands-AI",
"OpenHands",
]);
expect(parseGithubUrl("https://github.com/All-Hands-AI/")).toEqual([
"All-Hands-AI",
"",
]);
expect(parseGithubUrl("https://github.com/")).toEqual([]);
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.56.0",
"version": "0.55.0",
"private": true,
"type": "module",
"engines": {
@@ -25,7 +25,6 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"downshift": "^9.0.10",
@@ -47,15 +46,14 @@
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-resizable-panels": "^3.0.5",
"react-router": "^7.8.2",
"react-syntax-highlighter": "^15.6.6",
"react-textarea-autosize": "^8.5.9",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"sirv-cli": "^3.0.1",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.3.1",
"tailwind-scrollbar": "^4.0.2",
"vite": "^7.1.4",
"web-vitals": "^5.1.0",
"ws": "^8.18.2"
@@ -77,19 +75,12 @@
"lint:fix": "eslint src --ext .ts,.tsx,.js --fix && prettier --write src/**/*.{ts,tsx}",
"prepare": "cd .. && husky frontend/.husky",
"typecheck": "react-router typegen && tsc",
"typecheck:staged": "react-router typegen && npx tsc --noEmit --skipLibCheck",
"check-translation-completeness": "node scripts/check-translation-completeness.cjs"
},
"lint-staged": {
"src/**/*.{ts,tsx,js}": [
"eslint --fix",
"prettier --write"
],
"src/**/*.{ts,tsx}": [
"bash -c 'npm run typecheck:staged'"
],
"src/**/*": [
"npm run check-translation-completeness"
]
},
"devDependencies": {

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

View File

@@ -7,7 +7,7 @@
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.10.5'
const PACKAGE_VERSION = '2.10.4'
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()

View File

@@ -1,52 +0,0 @@
import { openHands } from "../open-hands-axios";
import { AuthenticateResponse, GitHubAccessTokenResponse } from "./auth.types";
import { GetConfigResponse } from "../option-service/option.types";
/**
* Authentication service for handling all authentication-related API calls
*/
class AuthService {
/**
* Authenticate with GitHub token
* @param appMode The application mode (saas or oss)
* @returns Response with authentication status and user info if successful
*/
static async authenticate(
appMode: GetConfigResponse["APP_MODE"],
): Promise<boolean> {
if (appMode === "oss") return true;
// Just make the request, if it succeeds (no exception thrown), return true
await openHands.post<AuthenticateResponse>("/api/authenticate");
return true;
}
/**
* Get GitHub access token from Keycloak callback
* @param code Code provided by GitHub
* @returns GitHub access token
*/
static async getGitHubAccessToken(
code: string,
): Promise<GitHubAccessTokenResponse> {
const { data } = await openHands.post<GitHubAccessTokenResponse>(
"/api/keycloak/callback",
{
code,
},
);
return data;
}
/**
* Logout user from the application
* @param appMode The application mode (saas or oss)
*/
static async logout(appMode: GetConfigResponse["APP_MODE"]): Promise<void> {
const endpoint =
appMode === "saas" ? "/api/logout" : "/api/unset-provider-tokens";
await openHands.post(endpoint);
}
}
export default AuthService;

View File

@@ -1,8 +0,0 @@
export interface AuthenticateResponse {
message?: string;
error?: string;
}
export interface GitHubAccessTokenResponse {
access_token: string;
}

View File

@@ -1,84 +0,0 @@
import { openHands } from "../open-hands-axios";
import {
CancelSubscriptionResponse,
SubscriptionAccess,
} from "./billing.types";
/**
* Billing Service API - Handles all billing-related API endpoints
*/
class BillingService {
/**
* Create a Stripe checkout session for credit purchase
* @param amount The amount to charge in dollars
* @returns The redirect URL for the checkout session
*/
static async createCheckoutSession(amount: number): Promise<string> {
const { data } = await openHands.post(
"/api/billing/create-checkout-session",
{
amount,
},
);
return data.redirect_url;
}
/**
* Create a customer setup session for payment method management
* @returns The redirect URL for the customer setup session
*/
static async createBillingSessionResponse(): Promise<string> {
const { data } = await openHands.post(
"/api/billing/create-customer-setup-session",
);
return data.redirect_url;
}
/**
* Get the user's current credit balance
* @returns The user's credit balance as a string
*/
static async getBalance(): Promise<string> {
const { data } = await openHands.get<{ credits: string }>(
"/api/billing/credits",
);
return data.credits;
}
/**
* Get the user's subscription access information
* @returns The user's subscription access details or null if not available
*/
static async getSubscriptionAccess(): Promise<SubscriptionAccess | null> {
const { data } = await openHands.get<SubscriptionAccess | null>(
"/api/billing/subscription-access",
);
return data;
}
/**
* Create a subscription checkout session for subscribing to a plan
* @returns The redirect URL for the subscription checkout session
*/
static async createSubscriptionCheckoutSession(): Promise<{
redirect_url?: string;
}> {
const { data } = await openHands.post(
"/api/billing/subscription-checkout-session",
);
return data;
}
/**
* Cancel the user's subscription
* @returns The response indicating the result of the cancellation request
*/
static async cancelSubscription(): Promise<CancelSubscriptionResponse> {
const { data } = await openHands.post<CancelSubscriptionResponse>(
"/api/billing/cancel-subscription",
);
return data;
}
}
export default BillingService;

View File

@@ -1,12 +0,0 @@
export type SubscriptionAccess = {
start_at: string;
end_at: string;
created_at: string;
cancelled_at?: string | null;
stripe_subscription_id?: string | null;
};
export interface CancelSubscriptionResponse {
status: string;
message: string;
}

View File

@@ -1,4 +1,4 @@
import ConversationService from "#/api/conversation-service/conversation-service.api";
import OpenHands from "#/api/open-hands";
/**
* Returns a URL compatible for the file service
@@ -6,4 +6,4 @@ import ConversationService from "#/api/conversation-service/conversation-service
* @returns URL of the conversation
*/
export const getConversationUrl = (conversationId: string) =>
ConversationService.getConversationUrl(conversationId);
OpenHands.getConversationUrl(conversationId);

View File

@@ -1,251 +0,0 @@
import { openHands } from "../open-hands-axios";
import { Provider } from "#/types/settings";
import { GitRepository, PaginatedBranchesResponse, Branch } from "#/types/git";
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
import { RepositoryMicroagent } from "#/types/microagent-management";
import {
MicroagentContentResponse,
GitChange,
GitChangeDiff,
} from "../open-hands.types";
import ConversationService from "../conversation-service/conversation-service.api";
/**
* Git Service API - Handles all Git-related API endpoints
*/
class GitService {
/**
* Search for Git repositories
* @param query Search query
* @param per_page Number of results per page
* @param selected_provider Git provider to search in
* @returns List of matching repositories
*/
static async searchGitRepositories(
query: string,
per_page = 5,
selected_provider?: Provider,
): Promise<GitRepository[]> {
const response = await openHands.get<GitRepository[]>(
"/api/user/search/repositories",
{
params: {
query,
per_page,
selected_provider,
},
},
);
return response.data;
}
/**
* Retrieve user's Git repositories
* @param selected_provider Git provider
* @param page Page number
* @param per_page Number of results per page
* @returns User's repositories with pagination info
*/
static async retrieveUserGitRepositories(
selected_provider: Provider,
page = 1,
per_page = 30,
) {
const { data } = await openHands.get<GitRepository[]>(
"/api/user/repositories",
{
params: {
selected_provider,
sort: "pushed",
page,
per_page,
},
},
);
const link =
data.length > 0 && data[0].link_header ? data[0].link_header : "";
const nextPage = extractNextPageFromLink(link);
return { data, nextPage };
}
/**
* Retrieve repositories from a specific installation
* @param selected_provider Git provider
* @param installationIndex Current installation index
* @param installations List of installation IDs
* @param page Page number
* @param per_page Number of results per page
* @returns Installation repositories with pagination info
*/
static async retrieveInstallationRepositories(
selected_provider: Provider,
installationIndex: number,
installations: string[],
page = 1,
per_page = 30,
) {
const installationId = installations[installationIndex];
const response = await openHands.get<GitRepository[]>(
"/api/user/repositories",
{
params: {
selected_provider,
sort: "pushed",
page,
per_page,
installation_id: installationId,
},
},
);
const link =
response.data.length > 0 && response.data[0].link_header
? response.data[0].link_header
: "";
const nextPage = extractNextPageFromLink(link);
let nextInstallation: number | null;
if (nextPage) {
nextInstallation = installationIndex;
} else if (installationIndex + 1 < installations.length) {
nextInstallation = installationIndex + 1;
} else {
nextInstallation = null;
}
return {
data: response.data,
nextPage,
installationIndex: nextInstallation,
};
}
/**
* Get repository branches
* @param repository Repository name
* @param page Page number
* @param perPage Number of results per page
* @returns Paginated branches response
*/
static async getRepositoryBranches(
repository: string,
page: number = 1,
perPage: number = 30,
): Promise<PaginatedBranchesResponse> {
const { data } = await openHands.get<PaginatedBranchesResponse>(
`/api/user/repository/branches?repository=${encodeURIComponent(repository)}&page=${page}&per_page=${perPage}`,
);
return data;
}
/**
* Search repository branches
* @param repository Repository name
* @param query Search query
* @param perPage Number of results per page
* @param selectedProvider Git provider
* @returns List of matching branches
*/
static async searchRepositoryBranches(
repository: string,
query: string,
perPage: number = 30,
selectedProvider?: Provider,
): Promise<Branch[]> {
const { data } = await openHands.get<Branch[]>(
`/api/user/search/branches`,
{
params: {
repository,
query,
per_page: perPage,
selected_provider: selectedProvider,
},
},
);
return data;
}
/**
* Get the available microagents for a repository
* @param owner The repository owner
* @param repo The repository name
* @returns The available microagents for the repository
*/
static async getRepositoryMicroagents(
owner: string,
repo: string,
): Promise<RepositoryMicroagent[]> {
const { data } = await openHands.get<RepositoryMicroagent[]>(
`/api/user/repository/${owner}/${repo}/microagents`,
);
return data;
}
/**
* Get the content of a specific microagent from a repository
* @param owner The repository owner
* @param repo The repository name
* @param filePath The path to the microagent file within the repository
* @returns The microagent content and metadata
*/
static async getRepositoryMicroagentContent(
owner: string,
repo: string,
filePath: string,
): Promise<MicroagentContentResponse> {
const { data } = await openHands.get<MicroagentContentResponse>(
`/api/user/repository/${owner}/${repo}/microagents/content`,
{
params: { file_path: filePath },
},
);
return data;
}
/**
* Get the user installation IDs
* @param provider The provider to get installation IDs for (github, bitbucket, etc.)
* @returns List of installation IDs
*/
static async getUserInstallationIds(provider: Provider): Promise<string[]> {
const { data } = await openHands.get<string[]>(
`/api/user/installations?provider=${provider}`,
);
return data;
}
/**
* Get git changes for a conversation
* @param conversationId The conversation ID
* @returns List of git changes
*/
static async getGitChanges(conversationId: string): Promise<GitChange[]> {
const url = `${ConversationService.getConversationUrl(conversationId)}/git/changes`;
const { data } = await openHands.get<GitChange[]>(url, {
headers: ConversationService.getConversationHeaders(),
});
return data;
}
/**
* Get git change diff for a specific file
* @param conversationId The conversation ID
* @param path The file path
* @returns Git change diff
*/
static async getGitChangeDiff(
conversationId: string,
path: string,
): Promise<GitChangeDiff> {
const url = `${ConversationService.getConversationUrl(conversationId)}/git/diff`;
const { data } = await openHands.get<GitChangeDiff>(url, {
params: { path },
headers: ConversationService.getConversationHeaders(),
});
return data;
}
}
export default GitService;

View File

@@ -2,23 +2,38 @@ import { AxiosHeaders } from "axios";
import {
Feedback,
FeedbackResponse,
GitHubAccessTokenResponse,
GetConfigResponse,
GetVSCodeUrlResponse,
AuthenticateResponse,
Conversation,
ResultSet,
GetTrajectoryResponse,
GitChangeDiff,
GitChange,
GetMicroagentsResponse,
GetMicroagentPromptResponse,
CreateMicroagent,
MicroagentContentResponse,
FileUploadSuccessResponse,
GetFilesResponse,
GetFileResponse,
} from "../open-hands.types";
import { openHands } from "../open-hands-axios";
import { Provider } from "#/types/settings";
import { SuggestedTask } from "#/utils/types";
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
import {
GitUser,
GitRepository,
PaginatedBranchesResponse,
Branch,
} from "#/types/git";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
import { RepositoryMicroagent } from "#/types/microagent-management";
import { BatchFeedbackData } from "#/hooks/query/use-batch-feedback";
import { SubscriptionAccess } from "#/types/billing";
class ConversationService {
class OpenHands {
private static currentConversation: Conversation | null = null;
/**
@@ -51,6 +66,42 @@ class ConversationService {
return `/api/conversations/${conversationId}`;
}
/**
* Retrieve the list of models available
* @returns List of models available
*/
static async getModels(): Promise<string[]> {
const { data } = await openHands.get<string[]>("/api/options/models");
return data;
}
/**
* Retrieve the list of agents available
* @returns List of agents available
*/
static async getAgents(): Promise<string[]> {
const { data } = await openHands.get<string[]>("/api/options/agents");
return data;
}
/**
* Retrieve the list of security analyzers available
* @returns List of security analyzers available
*/
static async getSecurityAnalyzers(): Promise<string[]> {
const { data } = await openHands.get<string[]>(
"/api/options/security-analyzers",
);
return data;
}
static async getConfig(): Promise<GetConfigResponse> {
const { data } = await openHands.get<GetConfigResponse>(
"/api/options/config",
);
return data;
}
static getConversationHeaders(): AxiosHeaders {
const headers = new AxiosHeaders();
const sessionApiKey = this.currentConversation?.session_api_key;
@@ -159,6 +210,20 @@ class ConversationService {
return data;
}
/**
* Authenticate with GitHub token
* @returns Response with authentication status and user info if successful
*/
static async authenticate(
appMode: GetConfigResponse["APP_MODE"],
): Promise<boolean> {
if (appMode === "oss") return true;
// Just make the request, if it succeeds (no exception thrown), return true
await openHands.post<AuthenticateResponse>("/api/authenticate");
return true;
}
/**
* Get the blob of the workspace zip
* @returns Blob of the workspace zip
@@ -184,6 +249,22 @@ class ConversationService {
return Object.keys(response.data.hosts);
}
/**
* @param code Code provided by GitHub
* @returns GitHub access token
*/
static async getGitHubAccessToken(
code: string,
): Promise<GitHubAccessTokenResponse> {
const { data } = await openHands.post<GitHubAccessTokenResponse>(
"/api/keycloak/callback",
{
code,
},
);
return data;
}
/**
* Get the VSCode URL
* @returns VSCode URL
@@ -310,6 +391,92 @@ class ConversationService {
return data;
}
/**
* Get the settings from the server or use the default settings if not found
*/
static async getSettings(): Promise<ApiSettings> {
const { data } = await openHands.get<ApiSettings>("/api/settings");
return data;
}
/**
* Save the settings to the server. Only valid settings are saved.
* @param settings - the settings to save
*/
static async saveSettings(
settings: Partial<PostApiSettings>,
): Promise<boolean> {
const data = await openHands.post("/api/settings", settings);
return data.status === 200;
}
static async createCheckoutSession(amount: number): Promise<string> {
const { data } = await openHands.post(
"/api/billing/create-checkout-session",
{
amount,
},
);
return data.redirect_url;
}
static async createBillingSessionResponse(): Promise<string> {
const { data } = await openHands.post(
"/api/billing/create-customer-setup-session",
);
return data.redirect_url;
}
static async getBalance(): Promise<string> {
const { data } = await openHands.get<{ credits: string }>(
"/api/billing/credits",
);
return data.credits;
}
static async getSubscriptionAccess(): Promise<SubscriptionAccess | null> {
const { data } = await openHands.get<SubscriptionAccess | null>(
"/api/billing/subscription-access",
);
return data;
}
static async getGitUser(): Promise<GitUser> {
const response = await openHands.get<GitUser>("/api/user/info");
const { data } = response;
const user: GitUser = {
id: data.id,
login: data.login,
avatar_url: data.avatar_url,
company: data.company,
name: data.name,
email: data.email,
};
return user;
}
static async searchGitRepositories(
query: string,
per_page = 5,
selected_provider?: Provider,
): Promise<GitRepository[]> {
const response = await openHands.get<GitRepository[]>(
"/api/user/search/repositories",
{
params: {
query,
per_page,
selected_provider,
},
},
);
return response.data;
}
static async getTrajectory(
conversationId: string,
): Promise<GetTrajectoryResponse> {
@@ -320,6 +487,131 @@ class ConversationService {
return data;
}
static async logout(appMode: GetConfigResponse["APP_MODE"]): Promise<void> {
const endpoint =
appMode === "saas" ? "/api/logout" : "/api/unset-provider-tokens";
await openHands.post(endpoint);
}
static async getGitChanges(conversationId: string): Promise<GitChange[]> {
const url = `${this.getConversationUrl(conversationId)}/git/changes`;
const { data } = await openHands.get<GitChange[]>(url, {
headers: this.getConversationHeaders(),
});
return data;
}
static async getGitChangeDiff(
conversationId: string,
path: string,
): Promise<GitChangeDiff> {
const url = `${this.getConversationUrl(conversationId)}/git/diff`;
const { data } = await openHands.get<GitChangeDiff>(url, {
params: { path },
headers: this.getConversationHeaders(),
});
return data;
}
/**
* @returns A list of repositories
*/
static async retrieveUserGitRepositories(
selected_provider: Provider,
page = 1,
per_page = 30,
) {
const { data } = await openHands.get<GitRepository[]>(
"/api/user/repositories",
{
params: {
selected_provider,
sort: "pushed",
page,
per_page,
},
},
);
const link =
data.length > 0 && data[0].link_header ? data[0].link_header : "";
const nextPage = extractNextPageFromLink(link);
return { data, nextPage };
}
static async retrieveInstallationRepositories(
selected_provider: Provider,
installationIndex: number,
installations: string[],
page = 1,
per_page = 30,
) {
const installationId = installations[installationIndex];
const response = await openHands.get<GitRepository[]>(
"/api/user/repositories",
{
params: {
selected_provider,
sort: "pushed",
page,
per_page,
installation_id: installationId,
},
},
);
const link =
response.data.length > 0 && response.data[0].link_header
? response.data[0].link_header
: "";
const nextPage = extractNextPageFromLink(link);
let nextInstallation: number | null;
if (nextPage) {
nextInstallation = installationIndex;
} else if (installationIndex + 1 < installations.length) {
nextInstallation = installationIndex + 1;
} else {
nextInstallation = null;
}
return {
data: response.data,
nextPage,
installationIndex: nextInstallation,
};
}
static async getRepositoryBranches(
repository: string,
page: number = 1,
perPage: number = 30,
): Promise<PaginatedBranchesResponse> {
const { data } = await openHands.get<PaginatedBranchesResponse>(
`/api/user/repository/branches?repository=${encodeURIComponent(repository)}&page=${page}&per_page=${perPage}`,
);
return data;
}
static async searchRepositoryBranches(
repository: string,
query: string,
perPage: number = 30,
selectedProvider?: Provider,
): Promise<Branch[]> {
const { data } = await openHands.get<Branch[]>(
`/api/user/search/branches`,
{
params: {
repository,
query,
per_page: perPage,
selected_provider: selectedProvider,
},
},
);
return data;
}
/**
* Get the available microagents associated with a conversation
* @param conversationId The ID of the conversation
@@ -335,6 +627,43 @@ class ConversationService {
return data;
}
/**
* Get the available microagents for a repository
* @param owner The repository owner
* @param repo The repository name
* @returns The available microagents for the repository
*/
static async getRepositoryMicroagents(
owner: string,
repo: string,
): Promise<RepositoryMicroagent[]> {
const { data } = await openHands.get<RepositoryMicroagent[]>(
`/api/user/repository/${owner}/${repo}/microagents`,
);
return data;
}
/**
* Get the content of a specific microagent from a repository
* @param owner The repository owner
* @param repo The repository name
* @param filePath The path to the microagent file within the repository
* @returns The microagent content and metadata
*/
static async getRepositoryMicroagentContent(
owner: string,
repo: string,
filePath: string,
): Promise<MicroagentContentResponse> {
const { data } = await openHands.get<MicroagentContentResponse>(
`/api/user/repository/${owner}/${repo}/microagents/content`,
{
params: { file_path: filePath },
},
);
return data;
}
static async getMicroagentPrompt(
conversationId: string,
eventId: number,
@@ -422,6 +751,39 @@ class ConversationService {
);
return response.data;
}
/**
* Get the user installation IDs
* @param provider The provider to get installation IDs for (github, bitbucket, etc.)
* @returns List of installation IDs
*/
static async getUserInstallationIds(provider: Provider): Promise<string[]> {
const { data } = await openHands.get<string[]>(
`/api/user/installations?provider=${provider}`,
);
return data;
}
static async getMicroagentManagementConversations(
selectedRepository: string,
pageId?: string,
limit: number = 100,
): Promise<Conversation[]> {
const params: Record<string, string | number> = {
limit,
selected_repository: selectedRepository,
};
if (pageId) {
params.page_id = pageId;
}
const { data } = await openHands.get<ResultSet<Conversation>>(
"/api/microagent-management/conversations",
{ params },
);
return data.results;
}
}
export default ConversationService;
export default OpenHands;

View File

@@ -26,6 +26,10 @@ export interface FeedbackResponse {
body: FeedbackBodyResponse;
}
export interface GitHubAccessTokenResponse {
access_token: string;
}
export interface AuthenticationResponse {
message: string;
login?: string; // Only present when allow list is enabled
@@ -40,6 +44,25 @@ export interface Feedback {
trajectory: unknown[];
}
export interface GetConfigResponse {
APP_MODE: "saas" | "oss";
APP_SLUG?: string;
GITHUB_CLIENT_ID: string;
POSTHOG_CLIENT_KEY: string;
PROVIDERS_CONFIGURED?: Provider[];
AUTH_URL?: string;
FEATURE_FLAGS: {
ENABLE_BILLING: boolean;
HIDE_LLM_SETTINGS: boolean;
ENABLE_JIRA: boolean;
ENABLE_JIRA_DC: boolean;
ENABLE_LINEAR: boolean;
};
MAINTENANCE?: {
startTime: string;
};
}
export interface GetVSCodeUrlResponse {
vscode_url: string | null;
error?: string;
@@ -50,6 +73,11 @@ export interface GetTrajectoryResponse {
error?: string;
}
export interface AuthenticateResponse {
message?: string;
error?: string;
}
export interface RepositorySelection {
selected_repository: string | null;
selected_branch: string | null;
@@ -116,11 +144,6 @@ export interface GetMicroagentPromptResponse {
prompt: string;
}
export interface IOption<T> {
label: string;
value: T;
}
export interface CreateMicroagent {
repo: string;
git_provider?: Provider;

View File

@@ -1,49 +0,0 @@
import { openHands } from "../open-hands-axios";
import { GetConfigResponse } from "./option.types";
/**
* Service for handling API options endpoints
*/
class OptionService {
/**
* Retrieve the list of models available
* @returns List of models available
*/
static async getModels(): Promise<string[]> {
const { data } = await openHands.get<string[]>("/api/options/models");
return data;
}
/**
* Retrieve the list of agents available
* @returns List of agents available
*/
static async getAgents(): Promise<string[]> {
const { data } = await openHands.get<string[]>("/api/options/agents");
return data;
}
/**
* Retrieve the list of security analyzers available
* @returns List of security analyzers available
*/
static async getSecurityAnalyzers(): Promise<string[]> {
const { data } = await openHands.get<string[]>(
"/api/options/security-analyzers",
);
return data;
}
/**
* Get the configuration from the server
* @returns Configuration response
*/
static async getConfig(): Promise<GetConfigResponse> {
const { data } = await openHands.get<GetConfigResponse>(
"/api/options/config",
);
return data;
}
}
export default OptionService;

View File

@@ -1,20 +0,0 @@
import { Provider } from "#/types/settings";
export interface GetConfigResponse {
APP_MODE: "saas" | "oss";
APP_SLUG?: string;
GITHUB_CLIENT_ID: string;
POSTHOG_CLIENT_KEY: string;
PROVIDERS_CONFIGURED?: Provider[];
AUTH_URL?: string;
FEATURE_FLAGS: {
ENABLE_BILLING: boolean;
HIDE_LLM_SETTINGS: boolean;
ENABLE_JIRA: boolean;
ENABLE_JIRA_DC: boolean;
ENABLE_LINEAR: boolean;
};
MAINTENANCE?: {
startTime: string;
};
}

View File

@@ -1,4 +1,4 @@
import { SuggestedTask } from "#/utils/types";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import { openHands } from "../open-hands-axios";
export class SuggestionsService {

View File

@@ -1,30 +0,0 @@
import { openHands } from "../open-hands-axios";
import { GitUser } from "#/types/git";
/**
* User Service API - Handles all user-related API endpoints
*/
class UserService {
/**
* Get the current user's Git information
* @returns Git user information
*/
static async getUser(): Promise<GitUser> {
const response = await openHands.get<GitUser>("/api/user/info");
const { data } = response;
const user: GitUser = {
id: data.id,
login: data.login,
avatar_url: data.avatar_url,
company: data.company,
name: data.name,
email: data.email,
};
return user;
}
}
export default UserService;

Some files were not shown because too many files have changed in this diff Show More