mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
2 Commits
feature/ta
...
openhands/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5236c3094a | ||
|
|
2d8c0168ae |
29
.github/workflows/enterprise-preview.yml
vendored
29
.github/workflows/enterprise-preview.yml
vendored
@@ -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
|
||||
9
.github/workflows/ghcr-build.yml
vendored
9
.github/workflows/ghcr-build.yml
vendored
@@ -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 \
|
||||
|
||||
70
.github/workflows/mdx-lint.yml
vendored
70
.github/workflows/mdx-lint.yml
vendored
@@ -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();
|
||||
"
|
||||
@@ -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.57-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.55-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -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.57-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.57-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.57
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.57-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.57-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.57
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
|
||||
|
||||
@@ -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.57-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.57-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.57
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.57-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:
|
||||
|
||||
@@ -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.57-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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
@@ -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.57-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.57 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55 \
|
||||
python -m openhands.cli.entry --override-cli-mode true
|
||||
```
|
||||
|
||||
|
||||
@@ -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.57-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.57 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -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.57-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.57-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.57
|
||||
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.57
|
||||
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
|
||||
|
||||
@@ -30,20 +30,6 @@ When running OpenHands, you'll need to set the following in the OpenHands UI thr
|
||||
|
||||
## Pricing
|
||||
|
||||
Pricing follows official API provider rates. Below are the current pricing details for OpenHands models:
|
||||
Pricing follows official API provider rates. [You can view model prices here.](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)
|
||||
|
||||
| Model | Input Cost (per 1M tokens) | Cached Input Cost (per 1M tokens) | Output Cost (per 1M tokens) | Max Input Tokens | Max Output Tokens |
|
||||
|-------|----------------------------|-----------------------------------|------------------------------|------------------|-------------------|
|
||||
| claude-opus-4-20250514 | $15.00 | $1.50 | $75.00 | 200,000 | 32,000 |
|
||||
| claude-sonnet-4-20250514 | $3.00 | $0.30 | $15.00 | 200,000 | 64,000 |
|
||||
| devstral-medium-2507 | $0.40 | N/A | $2.00 | 128,000 | 128,000 |
|
||||
| devstral-small-2505 | $0.10 | N/A | $0.30 | 128,000 | 128,000 |
|
||||
| devstral-small-2507 | $0.10 | N/A | $0.30 | 128,000 | 128,000 |
|
||||
| gemini-2.5-pro | $1.25 | $0.31 | $10.00 | 1,048,576 | 65,535 |
|
||||
| gpt-5-2025-08-07 | $1.25 | $0.125 | $10.00 | 400,000 | 128,000 |
|
||||
| gpt-5-mini-2025-08-07 | $0.25 | $0.025 | $2.00 | 400,000 | 128,000 |
|
||||
| o3 | $2.00 | $0.50 | $8.00 | 200,000 | 100,000 |
|
||||
| o4-mini | $1.10 | $0.28 | $4.40 | 200,000 | 100,000 |
|
||||
| qwen3-coder-480b | $0.40 | N/A | $1.60 | N/A | N/A |
|
||||
|
||||
**Note:** Cached input tokens are charged at a reduced rate when the same content is reused across requests. Models that don't support prompt caching show "N/A" for cached input cost.
|
||||
For `qwen3-coder-480b`, we charge the cheapest FP8 rate available on openrouter: \$0.4 per million input tokens and \$1.6 per million output tokens.
|
||||
|
||||
@@ -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.57-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.57-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.57
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -7,28 +7,14 @@ LABEL com.datadoghq.tags.service="deploy"
|
||||
LABEL com.datadoghq.tags.env="${DD_ENV}"
|
||||
|
||||
# Install Node.js v20+ and npm (which includes npx)
|
||||
# Apply security updates to fix CVEs
|
||||
RUN apt-get update && \
|
||||
apt-get install -y curl && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
apt-get install -y jq gettext && \
|
||||
# Apply security updates for packages with available fixes
|
||||
apt-get upgrade -y \
|
||||
libc-bin \
|
||||
libc6 \
|
||||
libgnutls30 \
|
||||
libsqlite3-0 \
|
||||
perl-base && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
apt-get clean
|
||||
|
||||
# Install Python packages with security fixes
|
||||
RUN pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gspread stripe python-keycloak asyncpg sqlalchemy[asyncio] resend tenacity slack-sdk ddtrace posthog "limits==5.2.0" coredis prometheus-client shap scikit-learn pandas numpy && \
|
||||
# Update packages with known CVE fixes
|
||||
pip install --upgrade \
|
||||
"mcp>=1.10.0" \
|
||||
"pillow>=11.3.0"
|
||||
RUN pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gspread stripe python-keycloak asyncpg sqlalchemy[asyncio] resend tenacity slack-sdk ddtrace posthog "limits==5.2.0" coredis prometheus-client shap scikit-learn pandas numpy
|
||||
|
||||
WORKDIR /app
|
||||
COPY enterprise .
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,7 @@ from experiments.constants import (
|
||||
ENABLE_EXPERIMENT_MANAGER,
|
||||
)
|
||||
from experiments.experiment_versions import (
|
||||
handle_claude4_vs_gpt5_experiment,
|
||||
handle_condenser_max_step_experiment,
|
||||
handle_system_prompt_experiment,
|
||||
)
|
||||
@@ -43,6 +44,9 @@ class SaaSExperimentManager(ExperimentManager):
|
||||
return conversation_settings
|
||||
|
||||
# Apply conversation-scoped experiments
|
||||
conversation_settings = handle_claude4_vs_gpt5_experiment(
|
||||
user_id, conversation_id, conversation_settings
|
||||
)
|
||||
conversation_settings = handle_condenser_max_step_experiment(
|
||||
user_id, conversation_id, conversation_settings
|
||||
)
|
||||
@@ -51,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
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
177
enterprise/poetry.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}'
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
287
frontend/__tests__/components/chat/action-suggestions.test.tsx
Normal file
287
frontend/__tests__/components/chat/action-suggestions.test.tsx
Normal 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")
|
||||
);
|
||||
});
|
||||
});
|
||||
256
frontend/__tests__/components/chat/chat-input.test.tsx
Normal file
256
frontend/__tests__/components/chat/chat-input.test.tsx
Normal 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("こんにちは");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
@@ -357,6 +359,72 @@ describe("ConversationCard", () => {
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show display cost button only when showOptions is true", async () => {
|
||||
const onContextMenuToggle = vi.fn();
|
||||
const { rerender } = renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for context menu to appear
|
||||
const menu = await screen.findByTestId("context-menu");
|
||||
expect(
|
||||
within(menu).queryByTestId("display-cost-button"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
showOptions
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for context menu to appear and check for display cost button
|
||||
const newMenu = await screen.findByTestId("context-menu");
|
||||
within(newMenu).getByTestId("display-cost-button");
|
||||
});
|
||||
|
||||
it("should show metrics modal when clicking the display cost button", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onContextMenuToggle = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
showOptions
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
const menu = screen.getByTestId("context-menu");
|
||||
const displayCostButton = within(menu).getByTestId("display-cost-button");
|
||||
|
||||
await user.click(displayCostButton);
|
||||
|
||||
// Verify if metrics modal is displayed by checking for the modal content
|
||||
expect(screen.getByTestId("metrics-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display the edit or delete options if the handler is not provided", async () => {
|
||||
const onContextMenuToggle = vi.fn();
|
||||
const { rerender } = renderWithProviders(
|
||||
@@ -431,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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClientConfig } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import React from "react";
|
||||
import { renderWithQueryAndI18n } from "test-utils";
|
||||
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", () => {
|
||||
@@ -17,7 +18,16 @@ describe("ConversationPanel", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const renderConversationPanel = () => renderWithQueryAndI18n(<RouterStub />);
|
||||
const renderConversationPanel = (config?: QueryClientConfig) =>
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
vi.mock("react-router", async (importOriginal) => ({
|
||||
@@ -75,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,
|
||||
});
|
||||
@@ -91,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,
|
||||
@@ -107,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"),
|
||||
);
|
||||
@@ -126,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);
|
||||
@@ -199,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) => {
|
||||
@@ -226,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);
|
||||
@@ -259,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,
|
||||
@@ -287,7 +280,15 @@ describe("ConversationPanel", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
renderWithQueryAndI18n(<MyRouterStub />);
|
||||
renderWithProviders(<MyRouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const toggleButton = screen.getByText("Toggle");
|
||||
|
||||
@@ -351,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,
|
||||
@@ -370,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
|
||||
@@ -421,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) {
|
||||
@@ -452,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);
|
||||
@@ -515,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,
|
||||
@@ -535,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 () => {
|
||||
@@ -593,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 () => {
|
||||
@@ -609,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
|
||||
@@ -625,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
|
||||
@@ -645,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
|
||||
@@ -665,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();
|
||||
@@ -679,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
|
||||
@@ -697,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();
|
||||
@@ -711,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
|
||||
@@ -724,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();
|
||||
@@ -743,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
|
||||
@@ -753,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", () => ({
|
||||
@@ -776,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
|
||||
@@ -806,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();
|
||||
@@ -842,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",
|
||||
});
|
||||
});
|
||||
@@ -857,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();
|
||||
@@ -871,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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
@@ -57,6 +56,11 @@ describe("MicroagentManagement", () => {
|
||||
const renderMicroagentManagement = (config?: QueryClientConfig) =>
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
addMicroagentModalVisible: false,
|
||||
updateMicroagentModalVisible: false,
|
||||
@@ -227,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",
|
||||
@@ -1286,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,
|
||||
@@ -1346,6 +1350,11 @@ describe("MicroagentManagement", () => {
|
||||
// Render with modal already visible in Redux state
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: null,
|
||||
addMicroagentModalVisible: true, // Start with modal visible
|
||||
@@ -1636,6 +1645,11 @@ describe("MicroagentManagement", () => {
|
||||
const renderMicroagentManagementMain = (selectedMicroagentItem: any) =>
|
||||
renderWithProviders(<MicroagentManagementMain />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
addMicroagentModalVisible: false,
|
||||
selectedRepository: {
|
||||
@@ -1969,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,
|
||||
@@ -1983,6 +1997,11 @@ describe("MicroagentManagement", () => {
|
||||
// Render with update modal visible in Redux state
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForUpdate,
|
||||
@@ -2017,6 +2036,11 @@ describe("MicroagentManagement", () => {
|
||||
// Render with update modal visible and selected microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForUpdate,
|
||||
@@ -2050,6 +2074,11 @@ describe("MicroagentManagement", () => {
|
||||
// Render with update modal visible and selected microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForUpdate,
|
||||
@@ -2088,6 +2117,11 @@ describe("MicroagentManagement", () => {
|
||||
// Render with update modal visible and selected microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForUpdate,
|
||||
@@ -2139,6 +2173,11 @@ describe("MicroagentManagement", () => {
|
||||
// Render with update modal visible
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForUpdate,
|
||||
@@ -2185,6 +2224,11 @@ describe("MicroagentManagement", () => {
|
||||
// Render with update modal visible
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForUpdate,
|
||||
@@ -2234,6 +2278,11 @@ describe("MicroagentManagement", () => {
|
||||
// Render with update modal visible but no microagent data
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: null,
|
||||
addMicroagentModalVisible: false,
|
||||
@@ -2265,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",
|
||||
@@ -2275,6 +2324,11 @@ describe("MicroagentManagement", () => {
|
||||
// Render with update modal visible and microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForUpdate,
|
||||
@@ -2309,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",
|
||||
@@ -2319,6 +2373,11 @@ describe("MicroagentManagement", () => {
|
||||
// Render with update modal visible and microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForUpdate,
|
||||
@@ -2501,6 +2560,11 @@ describe("MicroagentManagement", () => {
|
||||
// Render with selected microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForLearn,
|
||||
@@ -2536,6 +2600,11 @@ describe("MicroagentManagement", () => {
|
||||
// Render with selected microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForLearn,
|
||||
@@ -2578,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",
|
||||
@@ -2588,6 +2657,11 @@ describe("MicroagentManagement", () => {
|
||||
// Render with selected microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForLearn,
|
||||
@@ -2633,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",
|
||||
@@ -2643,6 +2717,11 @@ describe("MicroagentManagement", () => {
|
||||
// Render with selected microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForLearn,
|
||||
@@ -2686,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",
|
||||
@@ -2696,6 +2775,11 @@ describe("MicroagentManagement", () => {
|
||||
// Render with selected microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForLearn,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
11
frontend/__tests__/components/file-operations.test.tsx
Normal file
11
frontend/__tests__/components/file-operations.test.tsx
Normal 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");
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(', ')}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { act, screen } from "@testing-library/react";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { vi, describe, afterEach, it, expect } from "vitest";
|
||||
import { Command, useCommandStore } from "#/state/command-store";
|
||||
import { Command, appendInput, appendOutput } from "#/state/command-slice";
|
||||
import Terminal from "#/components/features/terminal/terminal";
|
||||
|
||||
const renderTerminal = (commands: Command[] = []) => {
|
||||
// Set initial commands in Zustand store
|
||||
useCommandStore.setState({ commands });
|
||||
return renderWithProviders(<Terminal />);
|
||||
};
|
||||
const renderTerminal = (commands: Command[] = []) =>
|
||||
renderWithProviders(<Terminal />, {
|
||||
preloadedState: {
|
||||
cmd: {
|
||||
commands,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe.skip("Terminal", () => {
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
@@ -55,25 +58,25 @@ describe.skip("Terminal", () => {
|
||||
});
|
||||
|
||||
it("should write commands to the terminal", () => {
|
||||
renderTerminal();
|
||||
const { store } = renderTerminal();
|
||||
|
||||
act(() => {
|
||||
useCommandStore.getState().appendInput("echo Hello");
|
||||
useCommandStore.getState().appendOutput("Hello");
|
||||
store.dispatch(appendInput("echo Hello"));
|
||||
store.dispatch(appendOutput("Hello"));
|
||||
});
|
||||
|
||||
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo Hello");
|
||||
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(2, "Hello");
|
||||
|
||||
act(() => {
|
||||
useCommandStore.getState().appendInput("echo World");
|
||||
store.dispatch(appendInput("echo World"));
|
||||
});
|
||||
|
||||
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(3, "echo World");
|
||||
});
|
||||
|
||||
it("should load and write commands to the terminal", () => {
|
||||
renderTerminal([
|
||||
const { store } = renderTerminal([
|
||||
{ type: "input", content: "echo Hello" },
|
||||
{ type: "output", content: "Hello" },
|
||||
]);
|
||||
@@ -82,17 +85,17 @@ describe.skip("Terminal", () => {
|
||||
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(2, "Hello");
|
||||
|
||||
act(() => {
|
||||
useCommandStore.getState().appendInput("echo Hello");
|
||||
store.dispatch(appendInput("echo Hello"));
|
||||
});
|
||||
|
||||
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(3, "echo Hello");
|
||||
});
|
||||
|
||||
it("should end the line with a dollar sign after writing a command", () => {
|
||||
renderTerminal();
|
||||
const { store } = renderTerminal();
|
||||
|
||||
act(() => {
|
||||
useCommandStore.getState().appendInput("echo Hello");
|
||||
store.dispatch(appendInput("echo Hello"));
|
||||
});
|
||||
|
||||
expect(mockTerminal.writeln).toHaveBeenCalledWith("echo Hello");
|
||||
|
||||
58
frontend/__tests__/components/upload-image-input.test.tsx
Normal file
58
frontend/__tests__/components/upload-image-input.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach } from "node:test";
|
||||
import { useTerminal } from "#/hooks/use-terminal";
|
||||
import { Command, useCommandStore } from "#/state/command-store";
|
||||
import { Command } from "#/state/command-slice";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
|
||||
@@ -19,10 +19,10 @@ interface TestTerminalComponentProps {
|
||||
commands: Command[];
|
||||
}
|
||||
|
||||
function TestTerminalComponent({ commands }: TestTerminalComponentProps) {
|
||||
// Set commands in Zustand store
|
||||
useCommandStore.setState({ commands });
|
||||
const ref = useTerminal();
|
||||
function TestTerminalComponent({
|
||||
commands,
|
||||
}: TestTerminalComponentProps) {
|
||||
const ref = useTerminal({ commands });
|
||||
return <div ref={ref} />;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ describe("useTerminal", () => {
|
||||
renderWithProviders(<TestTerminalComponent commands={[]} />, {
|
||||
preloadedState: {
|
||||
agent: { curAgentState: AgentState.RUNNING },
|
||||
cmd: { commands: [] },
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -73,6 +74,7 @@ describe("useTerminal", () => {
|
||||
renderWithProviders(<TestTerminalComponent commands={commands} />, {
|
||||
preloadedState: {
|
||||
agent: { curAgentState: AgentState.RUNNING },
|
||||
cmd: { commands },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -92,11 +94,17 @@ describe("useTerminal", () => {
|
||||
{ content: secret, type: "output" },
|
||||
];
|
||||
|
||||
renderWithProviders(<TestTerminalComponent commands={commands} />, {
|
||||
preloadedState: {
|
||||
agent: { curAgentState: AgentState.RUNNING },
|
||||
renderWithProviders(
|
||||
<TestTerminalComponent
|
||||
commands={commands}
|
||||
/>,
|
||||
{
|
||||
preloadedState: {
|
||||
agent: { curAgentState: AgentState.RUNNING },
|
||||
cmd: { commands },
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
// This test is no longer relevant as secrets filtering has been removed
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { useInitialQueryStore } from "../src/stores/initial-query-store";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import store from "../src/store";
|
||||
import {
|
||||
setInitialPrompt,
|
||||
clearInitialPrompt,
|
||||
} from "../src/state/initial-query-slice";
|
||||
|
||||
describe("Initial Query Behavior", () => {
|
||||
beforeEach(() => {
|
||||
// Reset the store before each test
|
||||
useInitialQueryStore.getState().reset();
|
||||
});
|
||||
|
||||
it("should clear initial query when clearInitialPrompt is called", () => {
|
||||
const { setInitialPrompt, clearInitialPrompt, initialPrompt } =
|
||||
useInitialQueryStore.getState();
|
||||
|
||||
it("should clear initial query when clearInitialPrompt is dispatched", () => {
|
||||
// Set up initial query in the store
|
||||
setInitialPrompt("test query");
|
||||
expect(useInitialQueryStore.getState().initialPrompt).toBe("test query");
|
||||
store.dispatch(setInitialPrompt("test query"));
|
||||
expect(store.getState().initialQuery.initialPrompt).toBe("test query");
|
||||
|
||||
// Clear the initial query
|
||||
clearInitialPrompt();
|
||||
store.dispatch(clearInitialPrompt());
|
||||
|
||||
// Verify initial query is cleared
|
||||
expect(useInitialQueryStore.getState().initialPrompt).toBeNull();
|
||||
expect(store.getState().initialQuery.initialPrompt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -13,26 +13,14 @@ vi.mock("#/store", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/state/command-store", () => ({
|
||||
useCommandStore: {
|
||||
getState: () => ({
|
||||
appendInput: mockAppendInput,
|
||||
}),
|
||||
},
|
||||
vi.mock("#/state/command-slice", () => ({
|
||||
appendInput: mockAppendInput,
|
||||
}));
|
||||
|
||||
vi.mock("#/state/jupyter-slice", () => ({
|
||||
appendJupyterInput: mockAppendJupyterInput,
|
||||
}));
|
||||
|
||||
vi.mock("#/state/metrics-slice", () => ({
|
||||
setMetrics: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/state/security-analyzer-slice", () => ({
|
||||
appendSecurityAnalyzerInput: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("handleActionMessage", () => {
|
||||
beforeEach(() => {
|
||||
// Clear all mocks before each test
|
||||
@@ -57,8 +45,7 @@ describe("handleActionMessage", () => {
|
||||
handleActionMessage(runAction);
|
||||
|
||||
// Check that appendInput was called with the command
|
||||
expect(mockAppendInput).toHaveBeenCalledWith("ls -la");
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockAppendInput("ls -la"));
|
||||
expect(mockAppendJupyterInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -72,8 +59,7 @@ describe("handleActionMessage", () => {
|
||||
args: {
|
||||
code: "print('Hello from Jupyter!')",
|
||||
},
|
||||
message:
|
||||
"Running Python code interactively: print('Hello from Jupyter!')",
|
||||
message: "Running Python code interactively: print('Hello from Jupyter!')",
|
||||
timestamp: "2023-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
@@ -81,9 +67,7 @@ describe("handleActionMessage", () => {
|
||||
handleActionMessage(ipythonAction);
|
||||
|
||||
// Check that appendJupyterInput was called with the code
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
mockAppendJupyterInput("print('Hello from Jupyter!')"),
|
||||
);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockAppendJupyterInput("print('Hello from Jupyter!')"));
|
||||
expect(mockAppendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -105,9 +89,7 @@ describe("handleActionMessage", () => {
|
||||
// Handle the action
|
||||
handleActionMessage(hiddenAction);
|
||||
|
||||
// Check that nothing was dispatched or called
|
||||
// Check that nothing was dispatched
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockAppendInput).not.toHaveBeenCalled();
|
||||
expect(mockAppendJupyterInput).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
51
frontend/__tests__/utils/browser-tab.test.ts
Normal file
51
frontend/__tests__/utils/browser-tab.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
9
frontend/__tests__/utils/format-ms.test.ts
Normal file
9
frontend/__tests__/utils/format-ms.test.ts
Normal 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");
|
||||
});
|
||||
@@ -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[] = [
|
||||
|
||||
29
frontend/__tests__/utils/i18n-test-utils.tsx
Normal file
29
frontend/__tests__/utils/i18n-test-utils.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
frontend/__tests__/utils/parse-github-url.test.ts
Normal file
20
frontend/__tests__/utils/parse-github-url.test.ts
Normal 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([]);
|
||||
});
|
||||
1019
frontend/package-lock.json
generated
1019
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.57.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,19 +46,17 @@
|
||||
"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",
|
||||
"zustand": "^5.0.8"
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev",
|
||||
@@ -78,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": {
|
||||
|
||||
5
frontend/postcss.config.js
Normal file
5
frontend/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
@@ -1,8 +0,0 @@
|
||||
export interface AuthenticateResponse {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface GitHubAccessTokenResponse {
|
||||
access_token: string;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user