Compare commits

..

3 Commits

Author SHA1 Message Date
openhands
ff5274f60b fix: include real errors in event detail, handle None payload explicitly, extract monitoring helper, add integration test
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-11 17:54:51 +00:00
openhands
d57e6c134c fix: address review feedback on executor + API client
- Implement download_automation_file using enterprise file_store
- Add background task tracking (_pending_tasks) with graceful shutdown
- Refactor execute_run into _prepare_run + _submit_and_monitor
- Add explicit None payload check with warning in find_matching_automations
- Improve API client error messages to include response body
- Add shutdown-aware heartbeat monitoring (should_continue check)

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-11 08:23:22 +00:00
openhands
d3cc121b08 [Automations Phase 1] Task 4: Executor + API Client
Implement the automation executor — a long-running process that:
1. Polls automation_events inbox for NEW events
2. Matches events to automations and creates PENDING runs
3. Claims PENDING runs via FOR UPDATE SKIP LOCKED
4. Submits automation scripts to V1 API for execution
5. Monitors running conversations with heartbeats
6. Recovers stale runs from crashed executors

Files:
- enterprise/services/openhands_api_client.py — httpx-based V1 API client
- enterprise/services/automation_executor.py — Three-phase executor logic
- enterprise/run_automation_executor.py — Entry point with signal handling
- enterprise/storage/automation.py — Stub SQLAlchemy models (Task 1 dep)
- enterprise/storage/automation_event.py — Stub event model (Task 1 dep)
- enterprise/tests/unit/services/ — 37 tests (all passing)

Part of RFC: https://github.com/OpenHands/OpenHands/issues/13275

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-10 18:43:54 +00:00
335 changed files with 5060 additions and 27717 deletions

2
.gitignore vendored
View File

@@ -234,8 +234,6 @@ yarn-error.log*
logs
ralph/
# agent
.envrc
/workspace

View File

@@ -39,8 +39,6 @@ then re-run the command to ensure it passes. Common issues include:
## Repository Structure
Backend:
- Located in the `openhands` directory
- The current V1 application server lives in `openhands/app_server/`. `make start-backend` still launches `openhands.server.listen:app`, which includes the V1 routes by default unless `ENABLE_V1=0`.
- For V1 web-app docs, LLM setup should point users to the Settings UI.
- Testing:
- All tests are in `tests/unit/test_*.py`
- To test new code, run `poetry run pytest tests/unit/test_xxx.py` where `xxx` is the appropriate file for the current functionality
@@ -344,30 +342,3 @@ To add a new LLM model to OpenHands, you need to update multiple files across bo
- Models appear in CLI provider selection based on the verified arrays
- The `organize_models_and_providers` function groups models by provider
- Default model selection prioritizes verified models for each provider
### Sandbox Settings API (SDK Credential Inheritance)
The sandbox settings API allows SDK-created conversations to inherit the user's SaaS credentials
(LLM config, secrets) securely via `LookupSecret`. Raw secret values only flow SaaS→sandbox,
never through the SDK client.
#### User Credentials with Exposed Secrets (in `openhands/app_server/user/user_router.py`):
- `GET /api/v1/users/me?expose_secrets=true` → Full user settings with unmasked secrets (e.g., `llm_api_key`)
- `GET /api/v1/users/me` → Full user settings (secrets masked, Bearer only)
Auth requirements for `expose_secrets=true`:
- Bearer token (proves user identity via `OPENHANDS_API_KEY`)
- `X-Session-API-Key` header (proves caller has an active sandbox owned by the authenticated user)
Called by `workspace.get_llm()` in the SDK to retrieve LLM config with the API key.
#### Sandbox-Scoped Secrets Endpoints (in `openhands/app_server/sandbox/sandbox_router.py`):
- `GET /sandboxes/{id}/settings/secrets` → list secret names (no values)
- `GET /sandboxes/{id}/settings/secrets/{name}` → raw secret value (called FROM sandbox)
#### Auth: `X-Session-API-Key` header, validated via `SandboxService.get_sandbox_by_session_api_key()`
#### Related SDK code (in `software-agent-sdk` repo):
- `openhands/sdk/llm/llm.py`: `LLM.api_key` accepts `SecretSource` (including `LookupSecret`)
- `openhands/workspace/cloud/workspace.py`: `get_llm()` and `get_secrets()` return LookupSecret-backed objects
- Tests: `tests/sdk/llm/test_llm_secret_source_api_key.py`, `tests/workspace/test_cloud_workspace_sdk_settings.py`

View File

@@ -1,105 +1,83 @@
# Contributing
Thanks for your interest in contributing to OpenHands! We're building the future of AI-powered software development, and we'd love for you to be part of this journey.
Thanks for your interest in contributing to OpenHands! We welcome and appreciate contributions.
## Our Vision
## Understanding OpenHands's CodeBase
The OpenHands community is built around the belief that AI and AI agents are going to fundamentally change the way we build software. If this is true, we should do everything we can to make sure that the benefits provided by such powerful technology are accessible to everyone.
To understand the codebase, please refer to the README in each module:
- [frontend](./frontend/README.md)
- [openhands](./openhands/README.md)
- [agenthub](./openhands/agenthub/README.md)
- [server](./openhands/server/README.md)
We believe in the power of open source to democratize access to cutting-edge AI technology. Just as the internet transformed how we share information, we envision a world where AI-powered development tools are available to every developer, regardless of their background or resources.
For benchmarks and evaluation, see the [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks) repository.
## Getting Started
## Setting up Your Development Environment
### Quick Ways to Contribute
We have a separate doc [Development.md](https://github.com/OpenHands/OpenHands/blob/main/Development.md) that tells
you how to set up a development workflow.
- **Use OpenHands** and [report issues](https://github.com/OpenHands/OpenHands/issues) you encounter
- **Give feedback** using the thumbs-up/thumbs-down buttons after each session
- **Star our repository** on [GitHub](https://github.com/OpenHands/OpenHands)
- **Share OpenHands** with other developers
## How Can I Contribute?
### Set Up Your Development Environment
There are many ways that you can contribute:
- **Requirements**: Linux/Mac/WSL, Docker, Python 3.12, Node.js 22+, Poetry 1.8+
- **Quick setup**: `make build`
- **Run locally**: `make run`
- **LLM setup (V1 web app)**: configure your model and API key in the Settings UI after the app starts
1. **Download and use** OpenHands, and send [issues](https://github.com/OpenHands/OpenHands/issues) when you encounter something that isn't working or a feature that you'd like to see.
2. **Send feedback** after each session by [clicking the thumbs-up thumbs-down buttons](https://docs.openhands.dev/usage/feedback), so we can see where things are working and failing, and also build an open dataset for training code agents.
3. **Improve the Codebase** by sending [PRs](#sending-pull-requests-to-openhands) (see details below). In particular, we have some [good first issues](https://github.com/OpenHands/OpenHands/labels/good%20first%20issue) that may be ones to start on.
Full details in our [Development Guide](./Development.md).
## What Can I Build?
### Find Your First Issue
Here are a few ways you can help improve the codebase.
- Browse [good first issues](https://github.com/OpenHands/OpenHands/labels/good%20first%20issue)
- Check our [project boards](https://github.com/OpenHands/OpenHands/projects) for organized tasks
- Join our [Slack community](https://openhands.dev/joinslack) to ask what needs help
#### UI/UX
## Understanding the Codebase
We're always looking to improve the look and feel of the application. If you've got a small fix
for something that's bugging you, feel free to open up a PR that changes the [`./frontend`](./frontend) directory.
- **[Frontend](./frontend/README.md)** - React application
- **[App Server (V1)](./openhands/app_server/README.md)** - Current FastAPI application server and REST API modules
- **[Agents](./openhands/agenthub/README.md)** - AI agent implementations
- **[Runtime](./openhands/runtime/README.md)** - Execution environments
- **[Evaluation](https://github.com/OpenHands/benchmarks)** - Testing and benchmarks
If you're looking to make a bigger change, add a new UI element, or significantly alter the style
of the application, please open an issue first, or better, join the #dev-ui-ux channel in our Slack
to gather consensus from our design team first.
## What Can You Build?
#### Improving the agent
### Frontend & UI/UX
- React & TypeScript development
- UI/UX improvements
- Mobile responsiveness
- Component libraries
Our main agent is the CodeAct agent. You can [see its prompts here](https://github.com/OpenHands/OpenHands/tree/main/openhands/agenthub/codeact_agent).
For bigger changes, join the #proj-gui channel in [Slack](https://openhands.dev/joinslack) first.
Changes to these prompts, and to the underlying behavior in Python, can have a huge impact on user experience.
You can try modifying the prompts to see how they change the behavior of the agent as you use the app
locally, but we will need to do an end-to-end evaluation of any changes here to ensure that the agent
is getting better over time.
### Agent Development
- Prompt engineering
- New agent types
- Agent evaluation
- Multi-agent systems
We use the [SWE-bench](https://www.swebench.com/) benchmark to test our agent. You can join the #evaluation
channel in Slack to learn more.
We use [SWE-bench](https://www.swebench.com/) to evaluate agents.
#### Adding a new agent
### Backend & Infrastructure
- Python development
- Runtime systems (Docker containers, sandboxes)
- Cloud integrations
- Performance optimization
You may want to experiment with building new types of agents. You can add an agent to [`openhands/agenthub`](./openhands/agenthub)
to help expand the capabilities of OpenHands.
### Testing & Quality Assurance
- Unit testing
- Integration testing
- Bug hunting
- Performance testing
#### Adding a new runtime
### Documentation & Education
- Technical documentation
- Translation
- Community support
The agent needs a place to run code and commands. When you run OpenHands on your laptop, it uses a Docker container
to do this by default. But there are other ways of creating a sandbox for the agent.
## Pull Request Process
If you work for a company that provides a cloud-based runtime, you could help us add support for that runtime
by implementing the [interface specified here](https://github.com/OpenHands/OpenHands/blob/main/openhands/runtime/base.py).
### Small Improvements
- Quick review and approval
- Ensure CI tests pass
- Include clear description of changes
#### Testing
### Core Agent Changes
These are evaluated based on:
- **Accuracy** - Does it make the agent better at solving problems?
- **Efficiency** - Does it improve speed or reduce resource usage?
- **Code Quality** - Is the code maintainable and well-tested?
Discuss major changes in [GitHub issues](https://github.com/OpenHands/OpenHands/issues) or [Slack](https://openhands.dev/joinslack) first.
When you write code, it is also good to write tests. Please navigate to the [`./tests`](./tests) folder to see existing
test suites. At the moment, we have these kinds of tests: [`unit`](./tests/unit), [`runtime`](./tests/runtime), and [`end-to-end (e2e)`](./tests/e2e).
Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure
quality of the project.
## Sending Pull Requests to OpenHands
You'll need to fork our repository to send us a Pull Request. You can learn more
about how to fork a GitHub repo and open a PR with your changes in [this article](https://medium.com/swlh/forks-and-pull-requests-how-to-contribute-to-github-repos-8843fac34ce8).
You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls).
### Pull Request title
### Pull Request Title Format
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), a valid PR title should begin with one of the following prefixes:
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), ideally a valid PR title should begin with one of the following prefixes:
- `feat`: A new feature
- `fix`: A bug fix
@@ -117,16 +95,45 @@ For example, a PR title could be:
- `refactor: modify package path`
- `feat(frontend): xxxx`, where `(frontend)` means that this PR mainly focuses on the frontend component.
### Pull Request Description
You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls).
- Explain what the PR does and why
- Link to related issues
- Include screenshots for UI changes
- If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix),
please include a short message that we can add to our changelog
### Pull Request description
## Need Help?
- If your PR is small (such as a typo fix), you can go brief.
- If it contains a lot of changes, it's better to write more details.
- **Slack**: [Join our community](https://openhands.dev/joinslack)
- **GitHub Issues**: [Open an issue](https://github.com/OpenHands/OpenHands/issues)
- **Email**: contact@openhands.dev
If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix)
please include a short message that we can add to our changelog.
## How to Make Effective Contributions
### Opening Issues
If you notice any bugs or have any feature requests please open them via the [issues page](https://github.com/OpenHands/OpenHands/issues). We will triage
based on how critical the bug is or how potentially useful the improvement is, discuss, and implement the ones that
the community has interest/effort for.
Further, if you see an issue you like, please leave a "thumbs-up" or a comment, which will help us prioritize.
### Making Pull Requests
We're generally happy to consider all pull requests with the evaluation process varying based on the type of change:
#### For Small Improvements
Small improvements with few downsides are typically reviewed and approved quickly.
One thing to check when making changes is to ensure that all continuous integration tests pass, which you can check
before getting a review.
#### For Core Agent Changes
We need to be more careful with changes to the core agent, as it is imperative to maintain high quality. These PRs are
evaluated based on three key metrics:
1. **Accuracy**
2. **Efficiency**
3. **Code Complexity**
If it improves accuracy, efficiency, or both with only a minimal change to code quality, that's great we're happy to merge it in!
If there are bigger tradeoffs (e.g. helping efficiency a lot and hurting accuracy a little) we might want to put it behind a feature flag.
Either way, please feel free to discuss on github issues or slack, and we will give guidance and preliminary feedback.

View File

@@ -6,196 +6,22 @@ If you wish to contribute your changes, check out the
on how to clone and setup the project initially before moving on. Otherwise,
you can clone the OpenHands project directly.
## Choose Your Setup
## Start the Server for Development
Select your operating system to see the specific setup instructions:
### 1. Requirements
- [macOS](#macos-setup)
- [Linux](#linux-setup)
- [Windows WSL](#windows-wsl-setup)
- [Dev Container](#dev-container)
- [Developing in Docker](#developing-in-docker)
- [No sudo access?](#develop-without-sudo-access)
- Linux, Mac OS, or [WSL on Windows](https://learn.microsoft.com/en-us/windows/wsl/install) [Ubuntu >= 22.04]
- [Docker](https://docs.docker.com/engine/install/) (For those on MacOS, make sure to allow the default Docker socket to be used from advanced settings!)
- [Python](https://www.python.org/downloads/) = 3.12
- [NodeJS](https://nodejs.org/en/download/package-manager) >= 22.x
- [Poetry](https://python-poetry.org/docs/#installing-with-the-official-installer) >= 1.8
- OS-specific dependencies:
- Ubuntu: build-essential => `sudo apt-get install build-essential python3.12-dev`
- WSL: netcat => `sudo apt-get install netcat`
---
Make sure you have all these dependencies installed before moving on to `make build`.
## macOS Setup
### 1. Install Prerequisites
You'll need the following installed:
- **Python 3.12** — `brew install python@3.12` (see the [official Homebrew Python docs](https://docs.brew.sh/Homebrew-and-Python) for details). Make sure `python3.12` is available in your PATH (the `make build` step will verify this).
- **Node.js >= 22** — `brew install node`
- **Poetry >= 1.8** — `brew install poetry`
- **Docker Desktop** — `brew install --cask docker`
- After installing, open Docker Desktop → **Settings → Advanced** → Enable **"Allow the default Docker socket to be used"**
### 2. Build and Setup the Environment
```bash
make build
```
### 3. Configure the Language Model
OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library.
For the V1 web app, start OpenHands and configure your model and API key in the Settings UI.
If you are running headless or CLI workflows, you can prepare local defaults with:
```bash
make setup-config
```
**Note on Alternative Models:**
See [our documentation](https://docs.openhands.dev/usage/llms) for recommended models.
### 4. Run the Application
```bash
# Run both backend and frontend
make run
# Or run separately:
make start-backend # Backend only on port 3000
make start-frontend # Frontend only on port 3001
```
These targets serve the current OpenHands V1 API by default. In the codebase, `make start-backend` runs `openhands.server.listen:app`, and that app includes the `openhands/app_server` V1 routes unless `ENABLE_V1=0`.
---
## Linux Setup
This guide covers Ubuntu/Debian. For other distributions, adapt the package manager commands accordingly.
### 1. Install Prerequisites
```bash
# Update package list
sudo apt update
# Install system dependencies
sudo apt install -y build-essential curl netcat software-properties-common
# Install Python 3.12
# Ubuntu 24.04+ and Debian 13+ ship with Python 3.12 — skip the PPA step if
# python3.12 --version already works on your system.
# The deadsnakes PPA is Ubuntu-only and needed for Ubuntu 22.04 or older:
sudo add-apt-repository -y ppa:deadsnakes/ppa
sudo apt update
sudo apt install -y python3.12 python3.12-dev python3.12-venv
# Install Node.js 22.x
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
# Install Poetry
curl -sSL https://install.python-poetry.org | python3 -
# Add Poetry to your PATH
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
# Install Docker
# Follow the official guide: https://docs.docker.com/engine/install/ubuntu/
# Quick version:
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER
# Log out and back in for Docker group changes to take effect
```
### 2. Build and Setup the Environment
```bash
make build
```
### 3. Configure the Language Model
See the [macOS section above](#3-configure-the-language-model) for guidance: configure your model and API key in the Settings UI.
### 4. Run the Application
```bash
# Run both backend and frontend
make run
# Or run separately:
make start-backend # Backend only on port 3000
make start-frontend # Frontend only on port 3001
```
---
## Windows WSL Setup
WSL2 with Ubuntu is recommended. The setup is similar to Linux, with a few WSL-specific considerations.
### 1. Install WSL2
**Option A: Windows 11 (Microsoft Store)**
The easiest way on Windows 11:
1. Open the **Microsoft Store** app
2. Search for **"Ubuntu 22.04 LTS"** or **"Ubuntu"**
3. Click **Install**
4. Launch Ubuntu from the Start menu
**Option B: PowerShell**
```powershell
# Run this in PowerShell as Administrator
wsl --install -d Ubuntu-22.04
```
After installation, restart your computer and open Ubuntu.
### 2. Install Prerequisites (in WSL Ubuntu)
Follow [Step 1 from the Linux setup](#1-install-prerequisites-1) to install system dependencies, Python 3.12, Node.js, and Poetry. Skip the Docker installation — Docker is provided through Docker Desktop below.
### 3. Configure Docker for WSL2
1. Install [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop)
2. Open Docker Desktop > Settings > General
3. Enable: "Use the WSL 2 based engine"
4. Go to Settings > Resources > WSL Integration
5. Enable integration with your Ubuntu distribution
**Important:** Keep your project files in the WSL filesystem (e.g., `~/workspace/openhands`), not in `/mnt/c`. Files accessed via `/mnt/c` will be significantly slower.
### 4. Build and Setup the Environment
```bash
make build
```
### 5. Configure the Language Model
See the [macOS section above](#3-configure-the-language-model) for the current V1 guidance: configure your model and API key in the Settings UI for the web app, and use `make setup-config` only for headless or CLI workflows.
### 6. Run the Application
```bash
# Run both backend and frontend
make run
# Or run separately:
make start-backend # Backend only on port 3000
make start-frontend # Frontend only on port 3001
```
Access the frontend at `http://localhost:3001` from your Windows browser.
---
## Dev Container
#### Dev container
There is a [dev container](https://containers.dev/) available which provides a
pre-configured environment with all the necessary dependencies installed if you
@@ -206,38 +32,7 @@ extension installed, you can open the project in a dev container by using the
_Dev Container: Reopen in Container_ command from the Command Palette
(Ctrl+Shift+P).
---
## Developing in Docker
If you don't want to install dependencies on your host machine, you can develop inside a Docker container.
### Quick Start
```bash
make docker-dev
```
For more details, see the [dev container documentation](./containers/dev/README.md).
### Alternative: Docker Run
If you just want to run OpenHands without setting up a dev environment:
```bash
make docker-run
```
If you don't have `make` installed, run:
```bash
cd ./containers/dev
./dev.sh
```
---
## Develop without sudo access
#### Develop without sudo access
If you want to develop without system admin/sudo access to upgrade/install `Python` and/or `NodeJS`, you can use
`conda` or `mamba` to manage the packages for you:
@@ -253,90 +48,159 @@ mamba install conda-forge::nodejs
mamba install conda-forge::poetry
```
---
### 2. Build and Setup The Environment
## Running OpenHands with OpenHands
You can use OpenHands to develop and improve OpenHands itself!
### Quick Start
Begin by building the project which includes setting up the environment and installing dependencies. This step ensures
that OpenHands is ready to run on your system:
```bash
export INSTALL_DOCKER=0
export RUNTIME=local
make build && make run
make build
```
Access the interface at:
- Local development: http://localhost:3001
- Remote/cloud environments: Use the appropriate external URL
### 3. Configuring the Language Model
For external access:
```bash
make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0
```
OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library.
---
## LLM Debugging
If you encounter issues with the Language Model, enable debug logging:
To configure the LM of your choice, run:
```bash
export DEBUG=1
# Restart the backend
make start-backend
make setup-config
```
Logs will be saved to `logs/llm/CURRENT_DATE/` for troubleshooting.
This command will prompt you to enter the LLM API key, model name, and other variables ensuring that OpenHands is
tailored to your specific needs. Note that the model name will apply only when you run headless. If you use the UI,
please set the model in the UI.
---
Note: If you have previously run OpenHands using the docker command, you may have already set some environment
variables in your terminal. The final configurations are set from highest to lowest priority:
Environment variables > config.toml variables > default variables
## Testing
**Note on Alternative Models:**
See [our documentation](https://docs.openhands.dev/usage/llms) for recommended models.
### Unit Tests
### 4. Running the application
#### Option A: Run the Full Application
Once the setup is complete, this command starts both the backend and frontend servers, allowing you to interact with OpenHands:
```bash
poetry run pytest ./tests/unit/test_*.py
make run
```
---
#### Option B: Individual Server Startup
## Adding Dependencies
- **Start the Backend Server:** If you prefer, you can start the backend server independently to focus on
backend-related tasks or configurations.
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`
2. Update the lock file: `poetry lock --no-update`
```bash
make start-backend
```
---
- **Start the Frontend Server:** Similarly, you can start the frontend server on its own to work on frontend-related
components or interface enhancements.
```bash
make start-frontend
```
## Using Existing Docker Images
### 5. Running OpenHands with OpenHands
To reduce build time, you can use an existing runtime image:
You can use OpenHands to develop and improve OpenHands itself! This is a powerful way to leverage AI assistance for contributing to the project.
```bash
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik
```
#### Quick Start
---
1. **Build and run OpenHands:**
## Help
```bash
export INSTALL_DOCKER=0
export RUNTIME=local
make build && make run
```
2. **Access the interface:**
- Local development: http://localhost:3001
- Remote/cloud environments: Use the appropriate external URL
3. **Configure for external access (if needed):**
```bash
# For external access (e.g., cloud environments)
make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0
```
### 6. LLM Debugging
If you encounter any issues with the Language Model (LM) or you're simply curious, export DEBUG=1 in the environment and restart the backend.
OpenHands will log the prompts and responses in the logs/llm/CURRENT_DATE directory, allowing you to identify the causes.
### 7. Help
Need help or info on available targets and commands? Use the help command for all the guidance you need with OpenHands.
```bash
make help
```
---
### 8. Testing
To run tests, refer to the following:
#### Unit tests
```bash
poetry run pytest ./tests/unit/test_*.py
```
### 9. Add or update dependency
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`.
2. Update the poetry.lock file via `poetry lock --no-update`.
### 10. Use existing Docker image
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/openhands/runtime:1.2-nikolaik`
## Develop inside Docker container
TL;DR
```bash
make docker-dev
```
See more details [here](./containers/dev/README.md).
If you are just interested in running `OpenHands` without installing all the required tools on your host.
```bash
make docker-run
```
If you do not have `make` on your host, run:
```bash
cd ./containers/dev
./dev.sh
```
You do need [Docker](https://docs.docker.com/engine/install/) installed on your host though.
## Key Documentation Resources
Here's a guide to the important documentation files in the repository:
- [/README.md](./README.md): Main project overview, features, and basic setup instructions
- [/Development.md](./Development.md) (this file): Comprehensive guide for developers working on OpenHands
- [/CONTRIBUTING.md](./CONTRIBUTING.md): Guidelines for contributing to the project, including code style and PR process
- [DOC_STYLE_GUIDE.md](https://github.com/OpenHands/docs/blob/main/openhands/DOC_STYLE_GUIDE.md): Standards for writing and maintaining project documentation
- [/openhands/app_server/README.md](./openhands/app_server/README.md): Current V1 application server implementation and REST API modules
- [/openhands/README.md](./openhands/README.md): Details about the backend Python implementation
- [/frontend/README.md](./frontend/README.md): Frontend React application setup and development guide
- [/containers/README.md](./containers/README.md): Information about Docker containers and deployment
- [/tests/unit/README.md](./tests/unit/README.md): Guide to writing and running unit tests
- [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks): Documentation for the evaluation framework and benchmarks
- [/skills/README.md](./skills/README.md): Information about the skills architecture and implementation
- [/openhands/server/README.md](./openhands/server/README.md): Server implementation details and API documentation
- [/openhands/runtime/README.md](./openhands/runtime/README.md): Documentation for the runtime environment and execution model

View File

@@ -51,6 +51,6 @@ NOTE: in the future we will simply replace the `GithubTokenManager` with keycloa
## User ID vs User Token
- In OpenHands, the entire app revolves around the GitHub token the user sets. `openhands/server` uses `request.state.github_token` for the entire app
- On Enterprise, the entire APP resolves around the Github User ID. This is because the cookie sets it, so `openhands/server` AND `enterprise/server` depend on it and completely ignore `request.state.github_token` (token is fetched from `GithubTokenManager` instead)
- On Enterprise, the entire APP resolves around the Github User ID. This is because the cookie sets it, so `openhands/server` AND `enterprise/server` depend on it and completly ignore `request.state.github_token` (token is fetched from `GithubTokenManager` instead)
Note that introducing GitHub User ID in OpenHands, for instance, will cause large breakages.

View File

@@ -1,13 +0,0 @@
# Enterprise Architecture Documentation
Architecture diagrams specific to the OpenHands SaaS/Enterprise deployment.
## Documentation
- [Authentication Flow](./authentication.md) - Keycloak-based authentication for SaaS deployment
- [External Integrations](./external-integrations.md) - GitHub, Slack, Jira, and other service integrations
## Related Documentation
For core OpenHands architecture (applicable to all deployments), see:
- [Core Architecture Documentation](../../../openhands/architecture/README.md)

View File

@@ -1,58 +0,0 @@
# Authentication Flow (SaaS Deployment)
OpenHands uses Keycloak for identity management in the SaaS deployment. The authentication flow involves multiple services:
```mermaid
sequenceDiagram
autonumber
participant User as User (Browser)
participant App as App Server
participant KC as Keycloak
participant IdP as Identity Provider<br/>(GitHub, Google, etc.)
participant DB as User Database
Note over User,DB: OAuth 2.0 / OIDC Authentication Flow
User->>App: Access OpenHands
App->>User: Redirect to Keycloak
User->>KC: Login request
KC->>User: Show login options
User->>KC: Select provider (e.g., GitHub)
KC->>IdP: OAuth redirect
User->>IdP: Authenticate
IdP-->>KC: OAuth callback + tokens
Note over KC: Create/update user session
KC-->>User: Redirect with auth code
User->>App: Auth code
App->>KC: Exchange code for tokens
KC-->>App: Access token + Refresh token
Note over App: Create signed JWT cookie
App->>DB: Store/update user record
App-->>User: Set keycloak_auth cookie
Note over User,DB: Subsequent Requests
User->>App: Request with cookie
Note over App: Verify JWT signature
App->>KC: Validate token (if needed)
KC-->>App: Token valid
Note over App: Extract user context
App-->>User: Authorized response
```
### Authentication Components
| Component | Purpose | Location |
|-----------|---------|----------|
| **Keycloak** | Identity provider, SSO, token management | External service |
| **UserAuth** | Abstract auth interface | `openhands/server/user_auth/user_auth.py` |
| **SaasUserAuth** | Keycloak implementation | `enterprise/server/auth/saas_user_auth.py` |
| **JWT Service** | Token signing/verification | `openhands/app_server/services/jwt_service.py` |
| **Auth Routes** | Login/logout endpoints | `enterprise/server/routes/auth.py` |
### Token Flow
1. **Keycloak Access Token**: Short-lived token for API access
2. **Keycloak Refresh Token**: Long-lived token to obtain new access tokens
3. **Signed JWT Cookie**: App Server's session cookie containing encrypted Keycloak tokens
4. **Provider Tokens**: OAuth tokens for GitHub, GitLab, etc. (stored separately for git operations)

View File

@@ -1,88 +0,0 @@
# External Integrations
OpenHands integrates with external services (GitHub, Slack, Jira, etc.) through webhook-based event handling:
```mermaid
sequenceDiagram
autonumber
participant Ext as External Service<br/>(GitHub/Slack/Jira)
participant App as App Server
participant IntRouter as Integration Router
participant Manager as Integration Manager
participant Conv as Conversation Service
participant Sandbox as Sandbox
Note over Ext,Sandbox: Webhook Event Flow (e.g., GitHub Issue Created)
Ext->>App: POST /api/integration/{service}/events
App->>IntRouter: Route to service handler
Note over IntRouter: Verify signature (HMAC)
IntRouter->>Manager: Parse event payload
Note over Manager: Extract context (repo, issue, user)
Note over Manager: Map external user → OpenHands user
Manager->>Conv: Create conversation (with issue context)
Conv->>Sandbox: Provision sandbox
Sandbox-->>Conv: Ready
Manager->>Sandbox: Start agent with task
Note over Ext,Sandbox: Agent Works on Task...
Sandbox-->>Manager: Task complete
Manager->>Ext: POST result<br/>(PR, comment, etc.)
Note over Ext,Sandbox: Callback Flow (Agent → External Service)
Sandbox->>App: Webhook callback<br/>/api/v1/webhooks
App->>Manager: Process callback
Manager->>Ext: Update external service
```
### Supported Integrations
| Integration | Trigger Events | Agent Actions |
|-------------|----------------|---------------|
| **GitHub** | Issue created, PR opened, @mention | Create PR, comment, push commits |
| **GitLab** | Issue created, MR opened | Create MR, comment, push commits |
| **Slack** | @mention in channel | Reply in thread, create tasks |
| **Jira** | Issue created/updated | Update ticket, add comments |
| **Linear** | Issue created | Update status, add comments |
### Integration Components
| Component | Purpose | Location |
|-----------|---------|----------|
| **Integration Routes** | Webhook endpoints per service | `enterprise/server/routes/integration/` |
| **Integration Managers** | Business logic per service | `enterprise/integrations/{service}/` |
| **Token Manager** | Store/retrieve OAuth tokens | `enterprise/server/auth/token_manager.py` |
| **Callback Processor** | Handle agent → service updates | `enterprise/integrations/{service}/*_callback_processor.py` |
### Integration Authentication
```
External Service (e.g., GitHub)
┌─────────────────────────────────┐
│ GitHub App Installation │
│ - Webhook secret for signature │
│ - App private key for API calls │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ User Account Linking │
│ - Keycloak user ID │
│ - GitHub user ID │
│ - Stored OAuth tokens │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ Agent Execution │
│ - Uses linked tokens for API │
│ - Can push, create PRs, comment │
└─────────────────────────────────┘
```

View File

@@ -60,9 +60,7 @@ class ResolverUserContext(UserContext):
return provider_token.token.get_secret_value()
return None
async def get_provider_tokens(
self, as_env_vars: bool = False
) -> PROVIDER_TOKEN_TYPE | dict[str, str] | None:
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
return await self.saas_user_auth.get_provider_tokens()
async def get_secrets(self) -> dict[str, SecretSource]:

View File

@@ -1,33 +0,0 @@
"""Add sandbox_grouping_strategy column to user, org, and user_settings tables.
Revision ID: 100
Revises: 099
Create Date: 2025-03-12
"""
import sqlalchemy as sa
from alembic import op
revision = '100'
down_revision = '099'
def upgrade() -> None:
op.add_column(
'user',
sa.Column('sandbox_grouping_strategy', sa.String, nullable=True),
)
op.add_column(
'org',
sa.Column('sandbox_grouping_strategy', sa.String, nullable=True),
)
op.add_column(
'user_settings',
sa.Column('sandbox_grouping_strategy', sa.String, nullable=True),
)
def downgrade() -> None:
op.drop_column('user_settings', 'sandbox_grouping_strategy')
op.drop_column('org', 'sandbox_grouping_strategy')
op.drop_column('user', 'sandbox_grouping_strategy')

View File

@@ -1,39 +0,0 @@
"""Add pending_messages table for server-side message queuing
Revision ID: 101
Revises: 100
Create Date: 2025-03-15 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '101'
down_revision: Union[str, None] = '100'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create pending_messages table for storing messages before conversation is ready.
Messages are stored temporarily until the conversation becomes ready, then
delivered and deleted regardless of success or failure.
"""
op.create_table(
'pending_messages',
sa.Column('id', sa.String(), primary_key=True),
sa.Column('conversation_id', sa.String(), nullable=False, index=True),
sa.Column('role', sa.String(20), nullable=False, server_default='user'),
sa.Column('content', sa.JSON, nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
)
def downgrade() -> None:
"""Remove pending_messages table."""
op.drop_table('pending_messages')

240
enterprise/poetry.lock generated
View File

@@ -602,14 +602,14 @@ files = [
[[package]]
name = "authlib"
version = "1.6.9"
version = "1.6.7"
description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3"},
{file = "authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04"},
{file = "authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0"},
{file = "authlib-1.6.7.tar.gz", hash = "sha256:dbf10100011d1e1b34048c9d120e83f13b35d69a826ae762b93d2fb5aafc337b"},
]
[package.dependencies]
@@ -6190,14 +6190,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
version = "1.14.0"
version = "1.12.0"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_agent_server-1.14.0-py3-none-any.whl", hash = "sha256:b1374b50d0ce93d825ba5ea907fcb8840b5ddc594c6752570c7c4c27be1a9fd1"},
{file = "openhands_agent_server-1.14.0.tar.gz", hash = "sha256:396de8d878c0a6c1c23d830f7407e34801ac850f4283ba296d7fe436d8b61488"},
{file = "openhands_agent_server-1.12.0-py3-none-any.whl", hash = "sha256:3bd62fef10092f1155af116a8a7417041d574eff9d4e4b6f7a24bfc432de2fad"},
{file = "openhands_agent_server-1.12.0.tar.gz", hash = "sha256:7ea7ce579175f713ed68b68cde5d685ef694627ac7bbff40d2e22913f065c46d"},
]
[package.dependencies]
@@ -6214,7 +6214,7 @@ wsproto = ">=1.2.0"
[[package]]
name = "openhands-ai"
version = "1.5.0"
version = "1.4.0"
description = "OpenHands: Code Less, Make More"
optional = false
python-versions = "^3.12,<3.14"
@@ -6259,12 +6259,11 @@ memory-profiler = ">=0.61"
numpy = "*"
openai = "2.8"
openhands-aci = "0.3.3"
openhands-agent-server = "1.14"
openhands-sdk = "1.14"
openhands-tools = "1.14"
openhands-agent-server = "1.12"
openhands-sdk = "1.12"
openhands-tools = "1.12"
opentelemetry-api = ">=1.33.1"
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1"
orjson = ">=3.11.6"
pathspec = ">=0.12.1"
pexpect = "*"
pg8000 = ">=1.31.5"
@@ -6276,7 +6275,7 @@ protobuf = ">=5.29.6,<6"
psutil = "*"
pybase62 = ">=1"
pygithub = ">=2.5"
pyjwt = ">=2.12.0"
pyjwt = ">=2.9"
pylatexenc = "*"
pypdf = ">=6.7.2"
python-docx = "*"
@@ -6316,14 +6315,14 @@ url = ".."
[[package]]
name = "openhands-sdk"
version = "1.14.0"
version = "1.12.0"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.14.0-py3-none-any.whl", hash = "sha256:64305b3a24445fd9480b63129e8e02f3a75fdbf8f4fcbf970760b7dc1d392090"},
{file = "openhands_sdk-1.14.0.tar.gz", hash = "sha256:30bda4b10291420f753d14aaa4ee67c87ba8d59ef3908bca999aa76daa033615"},
{file = "openhands_sdk-1.12.0-py3-none-any.whl", hash = "sha256:857793f5c27fd63c0d4d37762550e6c504a03dd06116475c23adcc14bb5c4c02"},
{file = "openhands_sdk-1.12.0.tar.gz", hash = "sha256:ac348e7134ea21e1ab453978962504aff8eb47e62df1fb7a503d769d55658ea9"},
]
[package.dependencies]
@@ -6346,14 +6345,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[[package]]
name = "openhands-tools"
version = "1.14.0"
version = "1.12.0"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.14.0-py3-none-any.whl", hash = "sha256:4df477fa53eafa15082d081143c80383aeb6d52b4448b989b86b811c297e5615"},
{file = "openhands_tools-1.14.0.tar.gz", hash = "sha256:2655a7de839b171539464fa39729b6a338dc37f914b58bd551378c4fc0ec71b5"},
{file = "openhands_tools-1.12.0-py3-none-any.whl", hash = "sha256:57207e9e30f9d7fe9121cd21b072580cfdc2a00831edeaf8e8d685d721bb9e33"},
{file = "openhands_tools-1.12.0.tar.gz", hash = "sha256:f2b4d81d0b6771f5416f8b702db09a14999fa8e553073bcf38f344e29aae770c"},
]
[package.dependencies]
@@ -6561,86 +6560,99 @@ files = [
[[package]]
name = "orjson"
version = "3.11.7"
version = "3.11.5"
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
optional = false
python-versions = ">=3.10"
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "orjson-3.11.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a02c833f38f36546ba65a452127633afce4cf0dd7296b753d3bb54e55e5c0174"},
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63c6e6738d7c3470ad01601e23376aa511e50e1f3931395b9f9c722406d1a67"},
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:043d3006b7d32c7e233b8cfb1f01c651013ea079e08dcef7189a29abd8befe11"},
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57036b27ac8a25d81112eb0cc9835cd4833c5b16e1467816adc0015f59e870dc"},
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:733ae23ada68b804b222c44affed76b39e30806d38660bf1eb200520d259cc16"},
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5fdfad2093bdd08245f2e204d977facd5f871c88c4a71230d5bcbd0e43bf6222"},
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cededd6738e1c153530793998e31c05086582b08315db48ab66649768f326baa"},
{file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:14f440c7268c8f8633d1b3d443a434bd70cb15686117ea6beff8fdc8f5917a1e"},
{file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3a2479753bbb95b0ebcf7969f562cdb9668e6d12416a35b0dda79febf89cdea2"},
{file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:71924496986275a737f38e3f22b4e0878882b3f7a310d2ff4dc96e812789120c"},
{file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4a9eefdc70bf8bf9857f0290f973dec534ac84c35cd6a7f4083be43e7170a8f"},
{file = "orjson-3.11.7-cp310-cp310-win32.whl", hash = "sha256:ae9e0b37a834cef7ce8f99de6498f8fad4a2c0bf6bfc3d02abd8ed56aa15b2de"},
{file = "orjson-3.11.7-cp310-cp310-win_amd64.whl", hash = "sha256:d772afdb22555f0c58cfc741bdae44180122b3616faa1ecadb595cd526e4c993"},
{file = "orjson-3.11.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9487abc2c2086e7c8eb9a211d2ce8855bae0e92586279d0d27b341d5ad76c85c"},
{file = "orjson-3.11.7-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:79cacb0b52f6004caf92405a7e1f11e6e2de8bdf9019e4f76b44ba045125cd6b"},
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e85fe4698b6a56d5e2ebf7ae87544d668eb6bde1ad1226c13f44663f20ec9e"},
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8d14b71c0b12963fe8a62aac87119f1afdf4cb88a400f61ca5ae581449efcb5"},
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91c81ef070c8f3220054115e1ef468b1c9ce8497b4e526cb9f68ab4dc0a7ac62"},
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:411ebaf34d735e25e358a6d9e7978954a9c9d58cfb47bc6683cdc3964cd2f910"},
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a16bcd08ab0bcdfc7e8801d9c4a9cc17e58418e4d48ddc6ded4e9e4b1a94062b"},
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c0b51672e466fd7e56230ffbae7f1639e18d0ce023351fb75da21b71bc2c960"},
{file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:136dcd6a2e796dfd9ffca9fc027d778567b0b7c9968d092842d3c323cef88aa8"},
{file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7ba61079379b0ae29e117db13bda5f28d939766e410d321ec1624afc6a0b0504"},
{file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0527a4510c300e3b406591b0ba69b5dc50031895b0a93743526a3fc45f59d26e"},
{file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a709e881723c9b18acddcfb8ba357322491ad553e277cf467e1e7e20e2d90561"},
{file = "orjson-3.11.7-cp311-cp311-win32.whl", hash = "sha256:c43b8b5bab288b6b90dac410cca7e986a4fa747a2e8f94615aea407da706980d"},
{file = "orjson-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:6543001328aa857187f905308a028935864aefe9968af3848401b6fe80dbb471"},
{file = "orjson-3.11.7-cp311-cp311-win_arm64.whl", hash = "sha256:1ee5cc7160a821dfe14f130bc8e63e7611051f964b463d9e2a3a573204446a4d"},
{file = "orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f"},
{file = "orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b"},
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a"},
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10"},
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa"},
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8"},
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f"},
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad"},
{file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867"},
{file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d"},
{file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab"},
{file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2"},
{file = "orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f"},
{file = "orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74"},
{file = "orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5"},
{file = "orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733"},
{file = "orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4"},
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785"},
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539"},
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1"},
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1"},
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705"},
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace"},
{file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b"},
{file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157"},
{file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3"},
{file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223"},
{file = "orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3"},
{file = "orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757"},
{file = "orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539"},
{file = "orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0"},
{file = "orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0"},
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6"},
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf"},
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5"},
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892"},
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e"},
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1"},
{file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183"},
{file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650"},
{file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141"},
{file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2"},
{file = "orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576"},
{file = "orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1"},
{file = "orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d"},
{file = "orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49"},
{file = "orjson-3.11.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:df9eadb2a6386d5ea2bfd81309c505e125cfc9ba2b1b99a97e60985b0b3665d1"},
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc70da619744467d8f1f49a8cadae5ec7bbe054e5232d95f92ed8737f8c5870"},
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:073aab025294c2f6fc0807201c76fdaed86f8fc4be52c440fb78fbb759a1ac09"},
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:835f26fa24ba0bb8c53ae2a9328d1706135b74ec653ed933869b74b6909e63fd"},
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667c132f1f3651c14522a119e4dd631fad98761fa960c55e8e7430bb2a1ba4ac"},
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42e8961196af655bb5e63ce6c60d25e8798cd4dfbc04f4203457fa3869322c2e"},
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75412ca06e20904c19170f8a24486c4e6c7887dea591ba18a1ab572f1300ee9f"},
{file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6af8680328c69e15324b5af3ae38abbfcf9cbec37b5346ebfd52339c3d7e8a18"},
{file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a86fe4ff4ea523eac8f4b57fdac319faf037d3c1be12405e6a7e86b3fbc4756a"},
{file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e607b49b1a106ee2086633167033afbd63f76f2999e9236f638b06b112b24ea7"},
{file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7339f41c244d0eea251637727f016b3d20050636695bc78345cce9029b189401"},
{file = "orjson-3.11.5-cp310-cp310-win32.whl", hash = "sha256:8be318da8413cdbbce77b8c5fac8d13f6eb0f0db41b30bb598631412619572e8"},
{file = "orjson-3.11.5-cp310-cp310-win_amd64.whl", hash = "sha256:b9f86d69ae822cabc2a0f6c099b43e8733dda788405cba2665595b7e8dd8d167"},
{file = "orjson-3.11.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9c8494625ad60a923af6b2b0bd74107146efe9b55099e20d7740d995f338fcd8"},
{file = "orjson-3.11.5-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:7bb2ce0b82bc9fd1168a513ddae7a857994b780b2945a8c51db4ab1c4b751ebc"},
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67394d3becd50b954c4ecd24ac90b5051ee7c903d167459f93e77fc6f5b4c968"},
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:298d2451f375e5f17b897794bcc3e7b821c0f32b4788b9bcae47ada24d7f3cf7"},
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa5e4244063db8e1d87e0f54c3f7522f14b2dc937e65d5241ef0076a096409fd"},
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1db2088b490761976c1b2e956d5d4e6409f3732e9d79cfa69f876c5248d1baf9"},
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2ed66358f32c24e10ceea518e16eb3549e34f33a9d51f99ce23b0251776a1ef"},
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2021afda46c1ed64d74b555065dbd4c2558d510d8cec5ea6a53001b3e5e82a9"},
{file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b42ffbed9128e547a1647a3e50bc88ab28ae9daa61713962e0d3dd35e820c125"},
{file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8d5f16195bb671a5dd3d1dbea758918bada8f6cc27de72bd64adfbd748770814"},
{file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c0e5d9f7a0227df2927d343a6e3859bebf9208b427c79bd31949abcc2fa32fa5"},
{file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23d04c4543e78f724c4dfe656b3791b5f98e4c9253e13b2636f1af5d90e4a880"},
{file = "orjson-3.11.5-cp311-cp311-win32.whl", hash = "sha256:c404603df4865f8e0afe981aa3c4b62b406e6d06049564d58934860b62b7f91d"},
{file = "orjson-3.11.5-cp311-cp311-win_amd64.whl", hash = "sha256:9645ef655735a74da4990c24ffbd6894828fbfa117bc97c1edd98c282ecb52e1"},
{file = "orjson-3.11.5-cp311-cp311-win_arm64.whl", hash = "sha256:1cbf2735722623fcdee8e712cbaaab9e372bbcb0c7924ad711b261c2eccf4a5c"},
{file = "orjson-3.11.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:334e5b4bff9ad101237c2d799d9fd45737752929753bf4faf4b207335a416b7d"},
{file = "orjson-3.11.5-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:ff770589960a86eae279f5d8aa536196ebda8273a2a07db2a54e82b93bc86626"},
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed24250e55efbcb0b35bed7caaec8cedf858ab2f9f2201f17b8938c618c8ca6f"},
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a66d7769e98a08a12a139049aac2f0ca3adae989817f8c43337455fbc7669b85"},
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86cfc555bfd5794d24c6a1903e558b50644e5e68e6471d66502ce5cb5fdef3f9"},
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a230065027bc2a025e944f9d4714976a81e7ecfa940923283bca7bbc1f10f626"},
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b29d36b60e606df01959c4b982729c8845c69d1963f88686608be9ced96dbfaa"},
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74099c6b230d4261fdc3169d50efc09abf38ace1a42ea2f9994b1d79153d477"},
{file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e697d06ad57dd0c7a737771d470eedc18e68dfdefcdd3b7de7f33dfda5b6212e"},
{file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e08ca8a6c851e95aaecc32bc44a5aa75d0ad26af8cdac7c77e4ed93acf3d5b69"},
{file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8b5f96c05fce7d0218df3fdfeb962d6b8cfff7e3e20264306b46dd8b217c0f3"},
{file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddbfdb5099b3e6ba6d6ea818f61997bb66de14b411357d24c4612cf1ebad08ca"},
{file = "orjson-3.11.5-cp312-cp312-win32.whl", hash = "sha256:9172578c4eb09dbfcf1657d43198de59b6cef4054de385365060ed50c458ac98"},
{file = "orjson-3.11.5-cp312-cp312-win_amd64.whl", hash = "sha256:2b91126e7b470ff2e75746f6f6ee32b9ab67b7a93c8ba1d15d3a0caaf16ec875"},
{file = "orjson-3.11.5-cp312-cp312-win_arm64.whl", hash = "sha256:acbc5fac7e06777555b0722b8ad5f574739e99ffe99467ed63da98f97f9ca0fe"},
{file = "orjson-3.11.5-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3b01799262081a4c47c035dd77c1301d40f568f77cc7ec1bb7db5d63b0a01629"},
{file = "orjson-3.11.5-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:61de247948108484779f57a9f406e4c84d636fa5a59e411e6352484985e8a7c3"},
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:894aea2e63d4f24a7f04a1908307c738d0dce992e9249e744b8f4e8dd9197f39"},
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ddc21521598dbe369d83d4d40338e23d4101dad21dae0e79fa20465dbace019f"},
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cce16ae2f5fb2c53c3eafdd1706cb7b6530a67cc1c17abe8ec747f5cd7c0c51"},
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e46c762d9f0e1cfb4ccc8515de7f349abbc95b59cb5a2bd68df5973fdef913f8"},
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7345c759276b798ccd6d77a87136029e71e66a8bbf2d2755cbdde1d82e78706"},
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75bc2e59e6a2ac1dd28901d07115abdebc4563b5b07dd612bf64260a201b1c7f"},
{file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:54aae9b654554c3b4edd61896b978568c6daa16af96fa4681c9b5babd469f863"},
{file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4bdd8d164a871c4ec773f9de0f6fe8769c2d6727879c37a9666ba4183b7f8228"},
{file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a261fef929bcf98a60713bf5e95ad067cea16ae345d9a35034e73c3990e927d2"},
{file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c028a394c766693c5c9909dec76b24f37e6a1b91999e8d0c0d5feecbe93c3e05"},
{file = "orjson-3.11.5-cp313-cp313-win32.whl", hash = "sha256:2cc79aaad1dfabe1bd2d50ee09814a1253164b3da4c00a78c458d82d04b3bdef"},
{file = "orjson-3.11.5-cp313-cp313-win_amd64.whl", hash = "sha256:ff7877d376add4e16b274e35a3f58b7f37b362abf4aa31863dadacdd20e3a583"},
{file = "orjson-3.11.5-cp313-cp313-win_arm64.whl", hash = "sha256:59ac72ea775c88b163ba8d21b0177628bd015c5dd060647bbab6e22da3aad287"},
{file = "orjson-3.11.5-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e446a8ea0a4c366ceafc7d97067bfd55292969143b57e3c846d87fc701e797a0"},
{file = "orjson-3.11.5-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:53deb5addae9c22bbe3739298f5f2196afa881ea75944e7720681c7080909a81"},
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82cd00d49d6063d2b8791da5d4f9d20539c5951f965e45ccf4e96d33505ce68f"},
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fd15f9fc8c203aeceff4fda211157fad114dde66e92e24097b3647a08f4ee9e"},
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9df95000fbe6777bf9820ae82ab7578e8662051bb5f83d71a28992f539d2cda7"},
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92a8d676748fca47ade5bc3da7430ed7767afe51b2f8100e3cd65e151c0eaceb"},
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa0f513be38b40234c77975e68805506cad5d57b3dfd8fe3baa7f4f4051e15b4"},
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1863e75b92891f553b7922ce4ee10ed06db061e104f2b7815de80cdcb135ad"},
{file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4be86b58e9ea262617b8ca6251a2f0d63cc132a6da4b5fcc8e0a4128782c829"},
{file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:b923c1c13fa02084eb38c9c065afd860a5cff58026813319a06949c3af5732ac"},
{file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1b6bd351202b2cd987f35a13b5e16471cf4d952b42a73c391cc537974c43ef6d"},
{file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb150d529637d541e6af06bbe3d02f5498d628b7f98267ff87647584293ab439"},
{file = "orjson-3.11.5-cp314-cp314-win32.whl", hash = "sha256:9cc1e55c884921434a84a0c3dd2699eb9f92e7b441d7f53f3941079ec6ce7499"},
{file = "orjson-3.11.5-cp314-cp314-win_amd64.whl", hash = "sha256:a4f3cb2d874e03bc7767c8f88adaa1a9a05cecea3712649c3b58589ec7317310"},
{file = "orjson-3.11.5-cp314-cp314-win_arm64.whl", hash = "sha256:38b22f476c351f9a1c43e5b07d8b5a02eb24a6ab8e75f700f7d479d4568346a5"},
{file = "orjson-3.11.5-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1b280e2d2d284a6713b0cfec7b08918ebe57df23e3f76b27586197afca3cb1e9"},
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c8d8a112b274fae8c5f0f01954cb0480137072c271f3f4958127b010dfefaec"},
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f0a2ae6f09ac7bd47d2d5a5305c1d9ed08ac057cda55bb0a49fa506f0d2da00"},
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0d87bd1896faac0d10b4f849016db81a63e4ec5df38757ffae84d45ab38aa71"},
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:801a821e8e6099b8c459ac7540b3c32dba6013437c57fdcaec205b169754f38c"},
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a0f6ac618c98c74b7fbc8c0172ba86f9e01dbf9f62aa0b1776c2231a7bffe5"},
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fea7339bdd22e6f1060c55ac31b6a755d86a5b2ad3657f2669ec243f8e3b2bdb"},
{file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4dad582bc93cef8f26513e12771e76385a7e6187fd713157e971c784112aad56"},
{file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:0522003e9f7fba91982e83a97fec0708f5a714c96c4209db7104e6b9d132f111"},
{file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:7403851e430a478440ecc1258bcbacbfbd8175f9ac1e39031a7121dd0de05ff8"},
{file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5f691263425d3177977c8d1dd896cde7b98d93cbf390b2544a090675e83a6a0a"},
{file = "orjson-3.11.5-cp39-cp39-win32.whl", hash = "sha256:61026196a1c4b968e1b1e540563e277843082e9e97d78afa03eb89315af531f1"},
{file = "orjson-3.11.5-cp39-cp39-win_amd64.whl", hash = "sha256:09b94b947ac08586af635ef922d69dc9bc63321527a3a04647f4986a73f4bd30"},
{file = "orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5"},
]
[[package]]
@@ -7917,14 +7929,14 @@ windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyjwt"
version = "2.12.1"
version = "2.10.1"
description = "JSON Web Token implementation in Python"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"},
{file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"},
{file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"},
{file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"},
]
[package.dependencies]
@@ -7932,9 +7944,9 @@ cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"cryp
[package.extras]
crypto = ["cryptography (>=3.4.0)"]
dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"]
dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"]
docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"]
tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"]
tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"]
[[package]]
name = "pylatexenc"
@@ -11587,14 +11599,14 @@ diagrams = ["jinja2", "railroad-diagrams"]
[[package]]
name = "pypdf"
version = "6.8.0"
version = "6.7.5"
description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7"},
{file = "pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b"},
{file = "pypdf-6.7.5-py3-none-any.whl", hash = "sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13"},
{file = "pypdf-6.7.5.tar.gz", hash = "sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d"},
]
[package.extras]
@@ -13759,22 +13771,24 @@ files = [
[[package]]
name = "tornado"
version = "6.5.5"
version = "6.5.4"
description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa"},
{file = "tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521"},
{file = "tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5"},
{file = "tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07"},
{file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e"},
{file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca"},
{file = "tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7"},
{file = "tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b"},
{file = "tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6"},
{file = "tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9"},
{file = "tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9"},
{file = "tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843"},
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17"},
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335"},
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f"},
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84"},
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f"},
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8"},
{file = "tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1"},
{file = "tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc"},
{file = "tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1"},
{file = "tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7"},
]
[[package]]

View File

@@ -0,0 +1,71 @@
"""Entry point for the automation executor.
Usage: python -m run_automation_executor
This runs as a Kubernetes Deployment (long-running). It polls the automation_events
inbox, matches events to automations, claims and executes runs, and monitors
conversation completion.
Environment variables:
OPENHANDS_API_URL Base URL for the V1 API (default: http://openhands-service:3000)
MAX_CONCURRENT_RUNS Max concurrent runs per executor (default: 5)
RUN_TIMEOUT_SECONDS Max time for a single run (default: 7200)
POLL_INTERVAL_SECONDS Fallback poll interval (default: 30)
HEARTBEAT_INTERVAL_SECONDS Heartbeat update interval (default: 60)
"""
import asyncio
import logging
import signal
import sys
logger = logging.getLogger('saas.automation.executor')
def _setup_logging() -> None:
"""Configure logging, deferring to enterprise logger if available."""
try:
from server.logger import setup_all_loggers
setup_all_loggers()
except ImportError:
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(name)s %(levelname)s %(message)s',
stream=sys.stdout,
)
def _install_signal_handlers(loop: asyncio.AbstractEventLoop) -> None:
"""Install signal handlers for graceful shutdown."""
from services.automation_executor import request_shutdown
def _handle_signal(signum: int, _frame: object) -> None:
sig_name = signal.Signals(signum).name
logger.info('Received %s, initiating graceful shutdown...', sig_name)
request_shutdown()
for sig in (signal.SIGTERM, signal.SIGINT):
signal.signal(sig, _handle_signal)
async def main() -> None:
from services.automation_executor import executor_main
await executor_main()
if __name__ == '__main__':
_setup_logging()
loop = asyncio.new_event_loop()
_install_signal_handlers(loop)
logger.info('Starting automation executor')
try:
loop.run_until_complete(main())
except KeyboardInterrupt:
logger.info('Interrupted by user')
finally:
loop.close()
logger.info('Automation executor process exiting')

View File

@@ -77,9 +77,6 @@ PERMITTED_CORS_ORIGINS = [
)
]
# Controls whether new orgs/users default to V1 API (env: DEFAULT_V1_ENABLED)
DEFAULT_V1_ENABLED = os.getenv('DEFAULT_V1_ENABLED', '1').lower() in ('1', 'true')
def build_litellm_proxy_model_path(model_name: str) -> str:
"""Build the LiteLLM proxy model path based on model name.

View File

@@ -12,8 +12,11 @@ from server.auth.auth_error import (
)
from server.auth.gitlab_sync import schedule_gitlab_repo_sync
from server.auth.saas_user_auth import SaasUserAuth, token_manager
from server.routes.auth import set_response_cookie
from server.utils.url_utils import get_cookie_domain, get_cookie_samesite
from server.routes.auth import (
get_cookie_domain,
get_cookie_samesite,
set_response_cookie,
)
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth.user_auth import AuthType, UserAuth, get_user_auth
@@ -90,8 +93,8 @@ class SetAuthCookieMiddleware:
if keycloak_auth_cookie:
response.delete_cookie(
key='keycloak_auth',
domain=get_cookie_domain(),
samesite=get_cookie_samesite(),
domain=get_cookie_domain(request),
samesite=get_cookie_samesite(request),
)
return response

View File

@@ -3,7 +3,7 @@ import json
import uuid
import warnings
from datetime import datetime, timezone
from typing import Annotated, Optional, cast
from typing import Annotated, Literal, Optional, cast
from urllib.parse import quote, urlencode
from uuid import UUID as parse_uuid
@@ -27,7 +27,7 @@ from server.auth.user.user_authorizer import (
depends_user_authorizer,
)
from server.config import sign_token
from server.constants import IS_FEATURE_ENV, IS_LOCAL_ENV
from server.constants import IS_FEATURE_ENV
from server.routes.event_webhook import _get_session_api_key, _get_user_id
from server.services.org_invitation_service import (
EmailMismatchError,
@@ -37,12 +37,12 @@ from server.services.org_invitation_service import (
UserAlreadyMemberError,
)
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
from server.utils.url_utils import get_cookie_domain, get_cookie_samesite, get_web_url
from sqlalchemy import select
from storage.database import a_session_maker
from storage.user import User
from storage.user_store import UserStore
from openhands.app_server.config import get_global_config
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderHandler
from openhands.integrations.service_types import ProviderType, TokenResponse
@@ -77,7 +77,7 @@ def set_response_cookie(
signed_token = sign_token(cookie_data, config.jwt_secret.get_secret_value()) # type: ignore
# Set secure cookie with signed token
domain = get_cookie_domain()
domain = get_cookie_domain(request)
if domain:
response.set_cookie(
key='keycloak_auth',
@@ -85,7 +85,7 @@ def set_response_cookie(
domain=domain,
httponly=True,
secure=secure,
samesite=get_cookie_samesite(),
samesite=get_cookie_samesite(request),
)
else:
response.set_cookie(
@@ -93,10 +93,30 @@ def set_response_cookie(
value=signed_token,
httponly=True,
secure=secure,
samesite=get_cookie_samesite(),
samesite=get_cookie_samesite(request),
)
def get_cookie_domain(request: Request) -> str | None:
# for now just use the full hostname except for staging stacks.
return (
None
if not request.url.hostname
or request.url.hostname.endswith('staging.all-hands.dev')
else request.url.hostname
)
def get_cookie_samesite(request: Request) -> Literal['lax', 'strict']:
# for localhost and feature/staging stacks we set it to 'lax' as the cookie domain won't allow 'strict'
return (
'lax'
if request.url.hostname == 'localhost'
or (request.url.hostname or '').endswith('staging.all-hands.dev')
else 'strict'
)
def _extract_oauth_state(state: str | None) -> tuple[str, str | None, str | None]:
"""Extract redirect URL, reCAPTCHA token, and invitation token from OAuth state.
@@ -120,6 +140,19 @@ def _extract_oauth_state(state: str | None) -> tuple[str, str | None, str | None
return state, None, None
# Keep alias for backward compatibility
def _extract_recaptcha_state(state: str | None) -> tuple[str, str | None]:
"""Extract redirect URL and reCAPTCHA token from OAuth state.
Deprecated: Use _extract_oauth_state instead.
Returns:
Tuple of (redirect_url, recaptcha_token). Token may be None.
"""
redirect_url, recaptcha_token, _ = _extract_oauth_state(state)
return redirect_url, recaptcha_token
@oauth_router.get('/keycloak/callback')
async def keycloak_callback(
request: Request,
@@ -150,7 +183,10 @@ async def keycloak_callback(
detail='Missing code in request params',
)
web_url = get_web_url(request)
web_url = get_global_config().web_url
if not web_url:
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
web_url = f'{scheme}://{request.url.netloc}'
redirect_uri = web_url + request.url.path
(
@@ -277,9 +313,7 @@ async def keycloak_callback(
else:
raise
verification_redirect_url = (
f'{web_url}/login?email_verification_required=true&user_id={user_id}'
)
verification_redirect_url = f'{request.base_url}login?email_verification_required=true&user_id={user_id}'
if rate_limited:
verification_redirect_url = f'{verification_redirect_url}&rate_limited=true'
@@ -440,7 +474,9 @@ async def keycloak_callback(
# If the user hasn't accepted the TOS, redirect to the TOS page
if not has_accepted_tos:
encoded_redirect_url = quote(redirect_url, safe='')
tos_redirect_url = f'{web_url}/accept-tos?redirect_url={encoded_redirect_url}'
tos_redirect_url = (
f'{request.base_url}accept-tos?redirect_url={encoded_redirect_url}'
)
if invitation_token:
tos_redirect_url = f'{tos_redirect_url}&invitation_success=true'
response = RedirectResponse(tos_redirect_url, status_code=302)
@@ -472,9 +508,10 @@ async def keycloak_offline_callback(code: str, state: str, request: Request):
status_code=status.HTTP_400_BAD_REQUEST,
content={'error': 'Missing code in request params'},
)
web_url = get_web_url(request)
redirect_uri = web_url + request.url.path
scheme = 'https'
if request.url.hostname == 'localhost':
scheme = 'http'
redirect_uri = f'{scheme}://{request.url.netloc}{request.url.path}'
logger.debug(f'code: {code}, redirect_uri: {redirect_uri}')
(
@@ -496,14 +533,15 @@ async def keycloak_offline_callback(code: str, state: str, request: Request):
)
redirect_url, _, _ = _extract_oauth_state(state)
return RedirectResponse(redirect_url if redirect_url else web_url, status_code=302)
return RedirectResponse(
redirect_url if redirect_url else request.base_url, status_code=302
)
@oauth_router.get('/github/callback')
async def github_dummy_callback(request: Request):
"""Callback for GitHub that just forwards the user to the app base URL."""
web_url = get_web_url(request)
return RedirectResponse(web_url, status_code=302)
return RedirectResponse(request.base_url, status_code=302)
@api_router.post('/authenticate')
@@ -525,8 +563,8 @@ async def authenticate(request: Request):
if keycloak_auth_cookie:
response.delete_cookie(
key='keycloak_auth',
domain=get_cookie_domain(),
samesite=get_cookie_samesite(),
domain=get_cookie_domain(request),
samesite=get_cookie_samesite(request),
)
return response
@@ -550,8 +588,7 @@ async def accept_tos(request: Request):
# Get redirect URL from request body
body = await request.json()
web_url = get_web_url(request)
redirect_url = body.get('redirect_url', str(web_url))
redirect_url = body.get('redirect_url', str(request.base_url))
# Update user settings with TOS acceptance
accepted_tos: datetime = datetime.now(timezone.utc).replace(tzinfo=None)
@@ -581,7 +618,7 @@ async def accept_tos(request: Request):
response=response,
keycloak_access_token=access_token.get_secret_value(),
keycloak_refresh_token=refresh_token.get_secret_value(),
secure=not IS_LOCAL_ENV,
secure=False if request.url.hostname == 'localhost' else True,
accepted_tos=True,
)
return response
@@ -598,8 +635,8 @@ async def logout(request: Request):
# Always delete the cookie regardless of what happens
response.delete_cookie(
key='keycloak_auth',
domain=get_cookie_domain(),
samesite=get_cookie_samesite(),
domain=get_cookie_domain(request),
samesite=get_cookie_samesite(request),
)
# Try to properly logout from Keycloak, but don't fail if it doesn't work

View File

@@ -11,8 +11,8 @@ from integrations import stripe_service
from pydantic import BaseModel
from server.constants import STRIPE_API_KEY
from server.logger import logger
from server.utils.url_utils import get_web_url
from sqlalchemy import select
from starlette.datastructures import URL
from storage.billing_session import BillingSession
from storage.database import a_session_maker
from storage.lite_llm_manager import LiteLlmManager
@@ -151,7 +151,7 @@ async def create_customer_setup_session(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Could not find or create customer for user',
)
base_url = get_web_url(request)
base_url = _get_base_url(request)
checkout_session = await stripe.checkout.Session.create_async(
customer=customer_info['customer_id'],
mode='setup',
@@ -170,7 +170,7 @@ async def create_checkout_session(
user_id: str = Depends(get_user_id),
) -> CreateBillingSessionResponse:
await validate_billing_enabled()
base_url = get_web_url(request)
base_url = _get_base_url(request)
customer_info = await stripe_service.find_or_create_customer_by_user_id(user_id)
if not customer_info:
raise HTTPException(
@@ -198,8 +198,8 @@ async def create_checkout_session(
saved_payment_method_options={
'payment_method_save': 'enabled',
},
success_url=f'{base_url}/api/billing/success?session_id={{CHECKOUT_SESSION_ID}}',
cancel_url=f'{base_url}/api/billing/cancel?session_id={{CHECKOUT_SESSION_ID}}',
success_url=f'{base_url}api/billing/success?session_id={{CHECKOUT_SESSION_ID}}',
cancel_url=f'{base_url}api/billing/cancel?session_id={{CHECKOUT_SESSION_ID}}',
)
logger.info(
'created_stripe_checkout_session',
@@ -300,7 +300,7 @@ async def success_callback(session_id: str, request: Request):
await session.commit()
return RedirectResponse(
f'{get_web_url(request)}/settings/billing?checkout=success', status_code=302
f'{_get_base_url(request)}settings/billing?checkout=success', status_code=302
)
@@ -325,9 +325,17 @@ async def cancel_callback(session_id: str, request: Request):
)
billing_session.status = 'cancelled'
billing_session.updated_at = datetime.now(UTC)
await session.merge(billing_session)
session.merge(billing_session)
await session.commit()
return RedirectResponse(
f'{get_web_url(request)}/settings/billing?checkout=cancel', status_code=302
f'{_get_base_url(request)}settings/billing?checkout=cancel', status_code=302
)
def _get_base_url(request: Request) -> URL:
# Never send any part of the credit card process over a non secure connection
base_url = request.base_url
if base_url.hostname != 'localhost':
base_url = base_url.replace(scheme='https')
return base_url

View File

@@ -7,10 +7,8 @@ from pydantic import BaseModel, field_validator
from server.auth.constants import KEYCLOAK_CLIENT_ID
from server.auth.keycloak_manager import get_keycloak_admin
from server.auth.saas_user_auth import SaasUserAuth
from server.constants import IS_LOCAL_ENV
from server.routes.auth import set_response_cookie
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
from server.utils.url_utils import get_web_url
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
@@ -89,7 +87,7 @@ async def update_email(
response=response,
keycloak_access_token=user_auth.access_token.get_secret_value(),
keycloak_refresh_token=user_auth.refresh_token.get_secret_value(),
secure=not IS_LOCAL_ENV,
secure=False if request.url.hostname == 'localhost' else True,
accepted_tos=user_auth.accepted_tos or False,
)
@@ -158,8 +156,8 @@ async def verified_email(request: Request):
await user_auth.refresh() # refresh so access token has updated email
user_auth.email_verified = True
await UserStore.update_user_email(user_id=user_auth.user_id, email_verified=True)
redirect_uri = f'{get_web_url(request)}/settings/user'
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
redirect_uri = f'{scheme}://{request.url.netloc}/settings/user'
response = RedirectResponse(redirect_uri, status_code=302)
# need to set auth cookie to the new tokens
@@ -182,10 +180,11 @@ async def verified_email(request: Request):
async def verify_email(request: Request, user_id: str, is_auth_flow: bool = False):
keycloak_admin = get_keycloak_admin()
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
if is_auth_flow:
redirect_uri = f'{get_web_url(request)}/login?email_verified=true'
redirect_uri = f'{scheme}://{request.url.netloc}/login?email_verified=true'
else:
redirect_uri = f'{get_web_url(request)}/api/email/verified'
redirect_uri = f'{scheme}://{request.url.netloc}/api/email/verified'
logger.info(f'Redirect URI: {redirect_uri}')
await keycloak_admin.a_send_verify_email(
user_id=user_id,

View File

@@ -6,7 +6,6 @@ from typing import Optional
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from server.utils.url_utils import get_web_url
from storage.api_key_store import ApiKeyStore
from storage.device_code_store import DeviceCodeStore
@@ -94,7 +93,7 @@ async def device_authorization(
expires_in=DEVICE_CODE_EXPIRES_IN,
)
base_url = get_web_url(http_request)
base_url = str(http_request.base_url).rstrip('/')
verification_uri = f'{base_url}/oauth/device/verify'
verification_uri_complete = (
f'{verification_uri}?user_code={device_code_entry.user_code}'

View File

@@ -365,12 +365,14 @@ class OrgInvitationService:
'Failed to set up organization access. Please try again.'
)
# Step 4.5: Fetch organization to get its LLM settings
org = await OrgStore.get_org_by_id(invitation.org_id)
if not org:
raise InvitationInvalidError('Organization not found')
# Step 5: Add user to organization
from storage.org_member_store import OrgMemberStore as OMS
org_member_kwargs = OMS.get_kwargs_from_settings(settings)
# Don't override with org defaults - use invitation-specified role
org_member_kwargs.pop('llm_model', None)
org_member_kwargs.pop('llm_base_url', None)
# Step 5: Add user to organization with inherited org LLM settings
# Get the llm_api_key as string (it's SecretStr | None in Settings)
llm_api_key = (
settings.llm_api_key.get_secret_value() if settings.llm_api_key else ''
@@ -382,9 +384,6 @@ class OrgInvitationService:
role_id=invitation.role_id,
llm_api_key=llm_api_key,
status='active',
llm_model=org.default_llm_model,
llm_base_url=org.default_llm_base_url,
max_iterations=org.default_max_iterations,
)
# Step 6: Mark invitation as accepted

View File

@@ -1,171 +0,0 @@
"""Implementation of SharedEventService for AWS S3.
This implementation provides read-only access to events from shared conversations:
- Validates that the conversation is shared before returning events
- Uses existing EventService for actual event retrieval
- Uses SharedConversationInfoService for shared conversation validation
Uses role-based authentication (no credentials needed).
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, AsyncGenerator
from uuid import UUID
import boto3
from fastapi import Request
from pydantic import Field
from server.sharing.shared_conversation_info_service import (
SharedConversationInfoService,
)
from server.sharing.shared_event_service import (
SharedEventService,
SharedEventServiceInjector,
)
from server.sharing.sql_shared_conversation_info_service import (
SQLSharedConversationInfoService,
)
from openhands.agent_server.models import EventPage, EventSortOrder
from openhands.app_server.event.aws_event_service import AwsEventService
from openhands.app_server.event.event_service import EventService
from openhands.app_server.event_callback.event_callback_models import EventKind
from openhands.app_server.services.injector import InjectorState
from openhands.sdk import Event
logger = logging.getLogger(__name__)
@dataclass
class AwsSharedEventService(SharedEventService):
"""Implementation of SharedEventService for AWS S3 that validates shared access.
Uses role-based authentication (no credentials needed).
"""
shared_conversation_info_service: SharedConversationInfoService
s3_client: Any
bucket_name: str
async def get_event_service(self, conversation_id: UUID) -> EventService | None:
shared_conversation_info = (
await self.shared_conversation_info_service.get_shared_conversation_info(
conversation_id
)
)
if shared_conversation_info is None:
return None
return AwsEventService(
s3_client=self.s3_client,
bucket_name=self.bucket_name,
prefix=Path('users'),
user_id=shared_conversation_info.created_by_user_id,
app_conversation_info_service=None,
app_conversation_info_load_tasks={},
)
async def get_shared_event(
self, conversation_id: UUID, event_id: UUID
) -> Event | None:
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
# First check if the conversation is shared
event_service = await self.get_event_service(conversation_id)
if event_service is None:
return None
# If conversation is shared, get the event
return await event_service.get_event(conversation_id, event_id)
async def search_shared_events(
self,
conversation_id: UUID,
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
page_id: str | None = None,
limit: int = 100,
) -> EventPage:
"""Search events for a specific shared conversation."""
# First check if the conversation is shared
event_service = await self.get_event_service(conversation_id)
if event_service is None:
# Return empty page if conversation is not shared
return EventPage(items=[], next_page_id=None)
# If conversation is shared, search events for this conversation
return await event_service.search_events(
conversation_id=conversation_id,
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
sort_order=sort_order,
page_id=page_id,
limit=limit,
)
async def count_shared_events(
self,
conversation_id: UUID,
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
) -> int:
"""Count events for a specific shared conversation."""
# First check if the conversation is shared
event_service = await self.get_event_service(conversation_id)
if event_service is None:
# Return empty page if conversation is not shared
return 0
# If conversation is shared, count events for this conversation
return await event_service.count_events(
conversation_id=conversation_id,
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
)
class AwsSharedEventServiceInjector(SharedEventServiceInjector):
bucket_name: str | None = Field(
default_factory=lambda: os.environ.get('FILE_STORE_PATH')
)
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[SharedEventService, None]:
# Define inline to prevent circular lookup
from openhands.app_server.config import get_db_session
async with get_db_session(state, request) as db_session:
shared_conversation_info_service = SQLSharedConversationInfoService(
db_session=db_session
)
bucket_name = self.bucket_name
if bucket_name is None:
raise ValueError(
'bucket_name is required. Set FILE_STORE_PATH environment variable.'
)
# Use role-based authentication - boto3 will automatically
# use IAM role credentials when running in AWS
s3_client = boto3.client(
's3',
endpoint_url=os.getenv('AWS_S3_ENDPOINT'),
)
service = AwsSharedEventService(
shared_conversation_info_service=shared_conversation_info_service,
s3_client=s3_client,
bucket_name=bucket_name,
)
yield service

View File

@@ -5,45 +5,19 @@ from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from server.sharing.shared_event_service import (
SharedEventService,
SharedEventServiceInjector,
from server.sharing.google_cloud_shared_event_service import (
GoogleCloudSharedEventServiceInjector,
)
from server.sharing.shared_event_service import SharedEventService
from openhands.agent_server.models import EventPage, EventSortOrder
from openhands.app_server.event_callback.event_callback_models import EventKind
from openhands.sdk import Event
from openhands.utils.environment import StorageProvider, get_storage_provider
def get_shared_event_service_injector() -> SharedEventServiceInjector:
"""Get the appropriate SharedEventServiceInjector based on configuration.
Uses get_storage_provider() to determine the storage backend.
See openhands.utils.environment for supported environment variables.
Note: Shared events only support AWS and GCP storage. Filesystem storage
falls back to GCP for shared events.
"""
provider = get_storage_provider()
if provider == StorageProvider.AWS:
from server.sharing.aws_shared_event_service import (
AwsSharedEventServiceInjector,
)
return AwsSharedEventServiceInjector()
else:
# GCP is the default for shared events (including filesystem fallback)
from server.sharing.google_cloud_shared_event_service import (
GoogleCloudSharedEventServiceInjector,
)
return GoogleCloudSharedEventServiceInjector()
router = APIRouter(prefix='/api/shared-events', tags=['Sharing'])
shared_event_service_dependency = Depends(get_shared_event_service_injector().depends)
shared_event_service_dependency = Depends(
GoogleCloudSharedEventServiceInjector().depends
)
# Read methods

View File

@@ -119,7 +119,6 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
created_at__lt: datetime | None = None,
updated_at__gte: datetime | None = None,
updated_at__lt: datetime | None = None,
sandbox_id__eq: str | None = None,
sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC,
page_id: str | None = None,
limit: int = 100,
@@ -142,7 +141,6 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
created_at__lt=created_at__lt,
updated_at__gte=updated_at__gte,
updated_at__lt=updated_at__lt,
sandbox_id__eq=sandbox_id__eq,
)
# Add sort order
@@ -200,7 +198,6 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
created_at__lt: datetime | None = None,
updated_at__gte: datetime | None = None,
updated_at__lt: datetime | None = None,
sandbox_id__eq: str | None = None,
) -> int:
"""Count conversations matching the given filters with SAAS metadata."""
query = (
@@ -223,7 +220,6 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
created_at__lt=created_at__lt,
updated_at__gte=updated_at__gte,
updated_at__lt=updated_at__lt,
sandbox_id__eq=sandbox_id__eq,
)
result = await self.db_session.execute(query)
@@ -238,7 +234,6 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
created_at__lt: datetime | None = None,
updated_at__gte: datetime | None = None,
updated_at__lt: datetime | None = None,
sandbox_id__eq: str | None = None,
):
"""Apply filters to query that includes SAAS metadata."""
# Apply the same filters as the base class
@@ -264,9 +259,6 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
StoredConversationMetadata.last_updated_at < updated_at__lt
)
if sandbox_id__eq is not None:
conditions.append(StoredConversationMetadata.sandbox_id == sandbox_id__eq)
if conditions:
query = query.where(*conditions)
return query
@@ -342,10 +334,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
await super().save_app_conversation_info(info)
# Get current user_id for SAAS metadata
# Fall back to info.created_by_user_id for webhook callbacks (which use ADMIN context)
user_id_str = await self.user_context.get_user_id()
if not user_id_str and info.created_by_user_id:
user_id_str = info.created_by_user_id
if user_id_str:
# Convert string user_id to UUID
user_id_uuid = UUID(user_id_str)

View File

@@ -1,172 +0,0 @@
"""Enterprise injector for PendingMessageService with SAAS filtering."""
from typing import AsyncGenerator
from uuid import UUID
from fastapi import Request
from sqlalchemy import select
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
from storage.user import User
from openhands.agent_server.models import ImageContent, TextContent
from openhands.app_server.errors import AuthError
from openhands.app_server.pending_messages.pending_message_models import (
PendingMessageResponse,
)
from openhands.app_server.pending_messages.pending_message_service import (
PendingMessageService,
PendingMessageServiceInjector,
SQLPendingMessageService,
)
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import ADMIN
from openhands.app_server.user.user_context import UserContext
class SaasSQLPendingMessageService(SQLPendingMessageService):
"""Extended SQLPendingMessageService with user and organization-based filtering.
This enterprise version ensures that:
- Users can only queue messages for conversations they own
- Organization isolation is enforced for multi-tenant deployments
"""
def __init__(self, db_session, user_context: UserContext):
super().__init__(db_session=db_session)
self.user_context = user_context
async def _get_current_user(self) -> User | None:
"""Get the current user using the existing db_session.
Returns:
User object or None if no user_id is available
"""
user_id_str = await self.user_context.get_user_id()
if not user_id_str:
return None
user_id_uuid = UUID(user_id_str)
result = await self.db_session.execute(
select(User).where(User.id == user_id_uuid)
)
return result.scalars().first()
async def _validate_conversation_ownership(self, conversation_id: str) -> None:
"""Validate that the current user owns the conversation.
This ensures multi-tenant isolation by checking:
- The conversation belongs to the current user
- The conversation belongs to the user's current organization
Args:
conversation_id: The conversation ID to validate (can be task-id or UUID)
Raises:
AuthError: If user doesn't own the conversation or authentication fails
"""
# For internal operations (e.g., processing pending messages during startup)
# we need a mode that bypasses filtering. The ADMIN context enables this.
if self.user_context == ADMIN:
return
user_id_str = await self.user_context.get_user_id()
if not user_id_str:
raise AuthError('User authentication required')
user_id_uuid = UUID(user_id_str)
# Check conversation ownership via SAAS metadata
query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == conversation_id
)
result = await self.db_session.execute(query)
saas_metadata = result.scalar_one_or_none()
# If no SAAS metadata exists, the conversation might be a new task-id
# that hasn't been linked to a conversation yet. Allow access in this case
# as the message will be validated when the conversation is created.
if saas_metadata is None:
return
# Verify user ownership
if saas_metadata.user_id != user_id_uuid:
raise AuthError('You do not have access to this conversation')
# Verify organization ownership if applicable
user = await self._get_current_user()
if user and user.current_org_id is not None:
if saas_metadata.org_id != user.current_org_id:
raise AuthError('Conversation belongs to a different organization')
async def add_message(
self,
conversation_id: str,
content: list[TextContent | ImageContent],
role: str = 'user',
) -> PendingMessageResponse:
"""Queue a message with ownership validation.
Args:
conversation_id: The conversation ID to queue the message for
content: Message content
role: Message role (default: 'user')
Returns:
PendingMessageResponse with the queued message info
Raises:
AuthError: If user doesn't own the conversation
"""
await self._validate_conversation_ownership(conversation_id)
return await super().add_message(conversation_id, content, role)
async def get_pending_messages(self, conversation_id: str):
"""Get pending messages with ownership validation.
Args:
conversation_id: The conversation ID to get messages for
Returns:
List of pending messages
Raises:
AuthError: If user doesn't own the conversation
"""
await self._validate_conversation_ownership(conversation_id)
return await super().get_pending_messages(conversation_id)
async def count_pending_messages(self, conversation_id: str) -> int:
"""Count pending messages with ownership validation.
Args:
conversation_id: The conversation ID to count messages for
Returns:
Number of pending messages
Raises:
AuthError: If user doesn't own the conversation
"""
await self._validate_conversation_ownership(conversation_id)
return await super().count_pending_messages(conversation_id)
class SaasPendingMessageServiceInjector(PendingMessageServiceInjector):
"""Enterprise injector for PendingMessageService with SAAS filtering."""
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[PendingMessageService, None]:
from openhands.app_server.config import (
get_db_session,
get_user_context,
)
async with (
get_user_context(state, request) as user_context,
get_db_session(state, request) as db_session,
):
service = SaasSQLPendingMessageService(
db_session=db_session, user_context=user_context
)
yield service

View File

@@ -1,38 +0,0 @@
from typing import Literal
from fastapi import Request
from server.constants import IS_FEATURE_ENV, IS_LOCAL_ENV, IS_STAGING_ENV
from starlette.datastructures import URL
from openhands.app_server.config import get_global_config
def get_web_url(request: Request):
web_url = get_global_config().web_url
if not web_url:
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
web_url = f'{scheme}://{request.url.netloc}'
else:
web_url = web_url.rstrip('/')
return web_url
def get_cookie_domain() -> str | None:
config = get_global_config()
web_url = config.web_url
# for now just use the full hostname except for staging stacks.
return (
URL(web_url).hostname
if web_url and not (IS_FEATURE_ENV or IS_STAGING_ENV or IS_LOCAL_ENV)
else None
)
def get_cookie_samesite() -> Literal['lax', 'strict']:
# for localhost and feature/staging stacks we set it to 'lax' as the cookie domain won't allow 'strict'
web_url = get_global_config().web_url
return (
'strict'
if web_url and not (IS_FEATURE_ENV or IS_STAGING_ENV or IS_LOCAL_ENV)
else 'lax'
)

View File

View File

@@ -0,0 +1,555 @@
"""Automation executor — processes events, claims and executes runs.
The executor is a long-running process with three phases:
1. Process inbox: match NEW events to automations, create PENDING runs
2. Claim and execute: claim PENDING runs, submit to V1 API, heartbeat
3. Stale recovery: recover RUNNING runs with expired heartbeats
"""
import asyncio
import logging
import os
import socket
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from services.openhands_api_client import OpenHandsAPIClient
from sqlalchemy import or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from storage.automation import Automation, AutomationRun
from storage.automation_event import AutomationEvent
logger = logging.getLogger('saas.automation.executor')
# Environment-configurable settings
POLL_INTERVAL_SECONDS = float(os.getenv('POLL_INTERVAL_SECONDS', '30'))
HEARTBEAT_INTERVAL_SECONDS = float(os.getenv('HEARTBEAT_INTERVAL_SECONDS', '60'))
RUN_TIMEOUT_SECONDS = float(os.getenv('RUN_TIMEOUT_SECONDS', '7200'))
MAX_CONCURRENT_RUNS = int(os.getenv('MAX_CONCURRENT_RUNS', '5'))
STALE_THRESHOLD_MINUTES = 5
MAX_EVENTS_PER_BATCH = 50
MAX_RETRIES_DEFAULT = 3
# Terminal conversation statuses
TERMINAL_STATUSES = frozenset({'STOPPED', 'ERROR', 'COMPLETED', 'CANCELLED'})
# Shutdown flag — set by signal handlers
_shutdown_event: asyncio.Event | None = None
# Background task tracking for graceful shutdown
_pending_tasks: set[asyncio.Task] = set()
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def get_shutdown_event() -> asyncio.Event:
global _shutdown_event
if _shutdown_event is None:
_shutdown_event = asyncio.Event()
return _shutdown_event
def should_continue() -> bool:
return not get_shutdown_event().is_set()
def request_shutdown() -> None:
get_shutdown_event().set()
# ---------------------------------------------------------------------------
# Phase 1: Process inbox (event matching)
# ---------------------------------------------------------------------------
async def find_matching_automations(
session: AsyncSession, event: AutomationEvent
) -> list[Automation]:
"""Find automations that match the given event.
Phase 1 supports cron and manual triggers only — both carry
``automation_id`` in the event payload.
"""
source_type = event.source_type
payload = event.payload
if payload is None:
logger.error('Event %s has None payload — possible data corruption', event.id)
return []
if source_type in ('cron', 'manual'):
automation_id = payload.get('automation_id')
if not automation_id:
logger.warning(
'Event %s (source=%s) missing automation_id in payload',
event.id,
source_type,
)
return []
result = await session.execute(
select(Automation).where(
Automation.id == automation_id,
Automation.enabled.is_(True),
)
)
automation = result.scalar_one_or_none()
return [automation] if automation else []
logger.debug('Unhandled event source_type=%s for event %s', source_type, event.id)
return []
async def process_new_events(session: AsyncSession) -> int:
"""Claim NEW events from inbox, match to automations, create runs.
Returns the number of events processed.
"""
result = await session.execute(
select(AutomationEvent)
.where(AutomationEvent.status == 'NEW')
.order_by(AutomationEvent.created_at)
.limit(MAX_EVENTS_PER_BATCH)
.with_for_update(skip_locked=True)
)
events = list(result.scalars())
processed = 0
for event in events:
try:
automations = await find_matching_automations(session, event)
if not automations:
event.status = 'NO_MATCH'
event.processed_at = utc_now()
else:
for automation in automations:
run = AutomationRun(
id=uuid4().hex,
automation_id=automation.id,
event_id=event.id,
status='PENDING',
event_payload=event.payload,
)
session.add(run)
event.status = 'PROCESSED'
event.processed_at = utc_now()
processed += 1
except Exception as e:
logger.exception('Error processing event %s', event.id)
event.status = 'ERROR'
event.error_detail = f'Failed during event matching: {type(e).__name__}: {e}'
event.processed_at = utc_now()
if processed:
await session.commit()
logger.info('Processed %d events', processed)
return processed
# ---------------------------------------------------------------------------
# Phase 2: Claim and execute runs
# ---------------------------------------------------------------------------
async def resolve_user_api_key(session: AsyncSession, user_id: str) -> str | None:
"""Look up a user's API key from the api_keys table.
Returns the first active key found, or None.
"""
from storage.api_key import ApiKey
result = await session.execute(
select(ApiKey.key).where(ApiKey.user_id == user_id).limit(1)
)
row = result.scalar_one_or_none()
return row
async def download_automation_file(file_store_key: str) -> bytes:
"""Download the automation .py file from object storage."""
try:
from openhands.server.shared import file_store
except ImportError as exc:
raise RuntimeError(
'file_store is not available — ensure the enterprise server '
'has been initialised before calling download_automation_file'
) from exc
content = file_store.read(file_store_key)
if isinstance(content, str):
return content.encode('utf-8')
return content
def is_terminal(conversation: dict) -> bool:
"""Check if a conversation has reached a terminal status."""
status = (conversation.get('status') or '').upper()
return status in TERMINAL_STATUSES
async def _prepare_run(
run: AutomationRun,
automation: Automation,
session_factory: object,
) -> tuple[str, bytes]:
"""Resolve the user's API key and download the automation file.
Returns:
(api_key, automation_file) tuple ready for submission.
Raises:
ValueError: If no API key is found.
RuntimeError: If file_store is unavailable.
"""
async with session_factory() as key_session:
api_key = await resolve_user_api_key(key_session, automation.user_id)
if not api_key:
raise ValueError(f'No API key found for user {automation.user_id}')
automation_file = await download_automation_file(automation.file_store_key)
return api_key, automation_file
async def _monitor_conversation(
run: AutomationRun,
conversation_id: str,
api_client: OpenHandsAPIClient,
api_key: str,
session_factory: object,
) -> bool:
"""Monitor a conversation until completion or timeout.
Returns True if completed successfully, False if shutdown requested.
Raises:
TimeoutError: If the run exceeds RUN_TIMEOUT_SECONDS.
"""
start_time = utc_now()
while should_continue():
elapsed = (utc_now() - start_time).total_seconds()
if elapsed > RUN_TIMEOUT_SECONDS:
raise TimeoutError(f'Run exceeded {RUN_TIMEOUT_SECONDS}s timeout')
await asyncio.sleep(HEARTBEAT_INTERVAL_SECONDS)
# Update heartbeat
async with session_factory() as session:
run_obj = await session.get(AutomationRun, run.id)
if run_obj:
run_obj.heartbeat_at = utc_now()
await session.commit()
# Check conversation status
conversation = (
await api_client.get_conversation(api_key, conversation_id) or {}
)
if is_terminal(conversation):
return True
return False # shutdown requested
async def _submit_and_monitor(
run: AutomationRun,
api_key: str,
automation_file: bytes,
automation: Automation,
api_client: OpenHandsAPIClient,
session_factory: object,
) -> None:
"""Submit the automation to the V1 API and monitor until completion.
Updates the run's conversation_id, sends heartbeats, and marks the
final status when the conversation reaches a terminal state.
"""
conversation = await api_client.start_conversation(
api_key=api_key,
automation_file=automation_file,
title=f'Automation: {automation.name}',
event_payload=run.event_payload,
)
conversation_id = conversation.get('app_conversation_id') or conversation.get(
'conversation_id'
)
# Persist conversation ID
async with session_factory() as update_session:
run_obj = await update_session.get(AutomationRun, run.id)
if run_obj:
run_obj.conversation_id = conversation_id
await update_session.commit()
# Monitor with heartbeats
completed = await _monitor_conversation(
run, conversation_id, api_client, api_key, session_factory
)
# Update final status
async with session_factory() as final_session:
run_obj = await final_session.get(AutomationRun, run.id)
if run_obj:
if not completed:
# Leave as RUNNING — stale recovery will handle it if needed.
# The conversation may still be running on the API side.
logger.info(
'Run %s left as RUNNING due to executor shutdown', run.id
)
else:
run_obj.status = 'COMPLETED'
run_obj.completed_at = utc_now()
logger.info('Run %s completed successfully', run.id)
await final_session.commit()
async def execute_run(
run: AutomationRun,
automation: Automation,
api_client: OpenHandsAPIClient,
session_factory: object,
) -> None:
"""Execute a single automation run end-to-end.
Orchestrates preparation (API key + file download) and submission/monitoring.
On failure, marks the run for retry or dead-letter.
"""
try:
api_key, automation_file = await _prepare_run(
run, automation, session_factory
)
await _submit_and_monitor(
run, api_key, automation_file, automation, api_client, session_factory
)
except Exception as e:
logger.exception('Run %s failed: %s', run.id, e)
await _mark_run_failed(run, str(e), session_factory)
async def _mark_run_failed(
run: AutomationRun, error: str, session_factory: object
) -> None:
"""Mark a run as FAILED or return to PENDING for retry."""
async with session_factory() as session:
run_obj = await session.get(AutomationRun, run.id)
if not run_obj:
return
run_obj.retry_count = (run_obj.retry_count or 0) + 1
run_obj.error_detail = error
if run_obj.retry_count >= (run_obj.max_retries or MAX_RETRIES_DEFAULT):
run_obj.status = 'DEAD_LETTER'
run_obj.completed_at = utc_now()
logger.error(
'Run %s moved to DEAD_LETTER after %d retries',
run.id,
run_obj.retry_count,
)
else:
run_obj.status = 'PENDING'
run_obj.claimed_by = None
backoff_seconds = 30 * (2 ** (run_obj.retry_count - 1))
run_obj.next_retry_at = utc_now() + timedelta(seconds=backoff_seconds)
logger.warning(
'Run %s returned to PENDING, retry %d/%d in %ds',
run.id,
run_obj.retry_count,
run_obj.max_retries or MAX_RETRIES_DEFAULT,
backoff_seconds,
)
await session.commit()
async def claim_and_execute_runs(
session: AsyncSession,
executor_id: str,
api_client: OpenHandsAPIClient,
session_factory: object,
) -> bool:
"""Claim a PENDING run and start executing it.
Returns True if a run was claimed, False otherwise.
"""
result = await session.execute(
select(AutomationRun)
.where(
AutomationRun.status == 'PENDING',
or_(
AutomationRun.next_retry_at.is_(None),
AutomationRun.next_retry_at <= utc_now(),
),
)
.order_by(AutomationRun.created_at)
.limit(1)
.with_for_update(skip_locked=True)
)
run = result.scalar_one_or_none()
if not run:
return False
# Claim the run
run.status = 'RUNNING'
run.claimed_by = executor_id
run.claimed_at = utc_now()
run.heartbeat_at = utc_now()
run.started_at = utc_now()
await session.commit()
# Load automation for the run
auto_result = await session.execute(
select(Automation).where(Automation.id == run.automation_id)
)
automation = auto_result.scalar_one_or_none()
if not automation:
logger.error('Automation %s not found for run %s', run.automation_id, run.id)
await _mark_run_failed(
run, f'Automation {run.automation_id} not found', session_factory
)
return True
# Execute in background (long-running) with task tracking
task = asyncio.create_task(
execute_run(run, automation, api_client, session_factory),
name=f'execute-run-{run.id}',
)
_pending_tasks.add(task)
task.add_done_callback(_pending_tasks.discard)
logger.info(
'Claimed run %s (automation=%s) by executor %s',
run.id,
run.automation_id,
executor_id,
)
return True
# ---------------------------------------------------------------------------
# Phase 3: Stale run recovery
# ---------------------------------------------------------------------------
async def recover_stale_runs(session: AsyncSession) -> int:
"""Mark RUNNING runs with expired heartbeats as PENDING for retry.
Returns the number of recovered runs.
"""
stale_threshold = utc_now() - timedelta(minutes=STALE_THRESHOLD_MINUTES)
timeout_threshold = utc_now() - timedelta(seconds=RUN_TIMEOUT_SECONDS)
# Recover stale runs (heartbeat expired)
result = await session.execute(
update(AutomationRun)
.where(
AutomationRun.status == 'RUNNING',
AutomationRun.heartbeat_at < stale_threshold,
AutomationRun.heartbeat_at >= timeout_threshold,
)
.values(
status='PENDING',
claimed_by=None,
retry_count=AutomationRun.retry_count + 1,
next_retry_at=utc_now() + timedelta(seconds=30),
)
.returning(AutomationRun.id)
)
recovered_rows = result.fetchall()
# Mark truly timed-out runs as DEAD_LETTER
timeout_result = await session.execute(
update(AutomationRun)
.where(
AutomationRun.status == 'RUNNING',
AutomationRun.heartbeat_at < timeout_threshold,
)
.values(
status='DEAD_LETTER',
error_detail='Run exceeded timeout',
completed_at=utc_now(),
)
.returning(AutomationRun.id)
)
timed_out_rows = timeout_result.fetchall()
await session.commit()
recovered_count = len(recovered_rows)
timed_out_count = len(timed_out_rows)
if recovered_count:
logger.warning('Recovered %d stale automation runs', recovered_count)
if timed_out_count:
logger.warning(
'Marked %d automation runs as DEAD_LETTER (timeout)', timed_out_count
)
return recovered_count + timed_out_count
# ---------------------------------------------------------------------------
# Main executor loop
# ---------------------------------------------------------------------------
async def executor_main(session_factory: object | None = None) -> None:
"""Main executor loop.
Args:
session_factory: Async context manager that yields AsyncSession instances.
If None, uses the default ``a_session_maker`` from database module.
"""
if session_factory is None:
from storage.database import a_session_maker
session_factory = a_session_maker
executor_id = f'executor-{socket.gethostname()}-{os.getpid()}'
api_url = os.getenv('OPENHANDS_API_URL', 'http://openhands-service:3000')
api_client = OpenHandsAPIClient(base_url=api_url)
logger.info(
'Automation executor %s starting (api_url=%s, poll=%ss, heartbeat=%ss)',
executor_id,
api_url,
POLL_INTERVAL_SECONDS,
HEARTBEAT_INTERVAL_SECONDS,
)
try:
while should_continue():
try:
async with session_factory() as session:
await process_new_events(session)
async with session_factory() as session:
await claim_and_execute_runs(
session, executor_id, api_client, session_factory
)
async with session_factory() as session:
await recover_stale_runs(session)
except Exception:
logger.exception('Error in executor main loop iteration')
# Wait for next poll interval (or early wakeup on shutdown)
try:
await asyncio.wait_for(
get_shutdown_event().wait(),
timeout=POLL_INTERVAL_SECONDS,
)
except asyncio.TimeoutError:
pass # Normal — poll interval elapsed
finally:
if _pending_tasks:
logger.info(
'Waiting for %d running tasks to complete...', len(_pending_tasks)
)
await asyncio.gather(*_pending_tasks, return_exceptions=True)
await api_client.close()
logger.info('Automation executor %s shut down', executor_id)

View File

@@ -0,0 +1,93 @@
"""HTTP client for the main OpenHands V1 API (internal cluster calls).
Used by the automation executor to create and monitor conversations
in the main OpenHands server.
"""
import base64
import logging
import httpx
logger = logging.getLogger('saas.automation.api_client')
def _raise_with_body(resp: httpx.Response) -> None:
"""Call raise_for_status, enriching the error with the response body."""
try:
resp.raise_for_status()
except httpx.HTTPStatusError as e:
error_body = resp.text[:500] if resp.text else 'no response body'
raise httpx.HTTPStatusError(
f'{e.args[0]} — Response: {error_body}',
request=e.request,
response=e.response,
) from e
class OpenHandsAPIClient:
"""Async HTTP client for the OpenHands V1 API."""
def __init__(self, base_url: str = 'http://openhands-service:3000'):
self.base_url = base_url.rstrip('/')
self.client = httpx.AsyncClient(base_url=self.base_url, timeout=30.0)
async def start_conversation(
self,
api_key: str,
automation_file: bytes,
title: str,
event_payload: dict | None = None,
) -> dict:
"""Submit an SDK script for sandboxed execution via V1 API.
Args:
api_key: User's API key for authentication.
automation_file: Raw bytes of the .py automation script.
title: Display title for the conversation.
event_payload: Optional trigger event data (injected as env var).
Returns:
Parsed JSON response containing conversation details.
Raises:
httpx.HTTPStatusError: If the API returns a non-2xx status.
"""
resp = await self.client.post(
'/api/v1/app-conversations',
json={
'automation_file': base64.b64encode(automation_file).decode(),
'trigger': 'automation',
'title': title,
'event_payload': event_payload,
},
headers={'Authorization': f'Bearer {api_key}'},
)
_raise_with_body(resp)
return resp.json()
async def get_conversation(self, api_key: str, conversation_id: str) -> dict | None:
"""Get conversation status.
Args:
api_key: User's API key for authentication.
conversation_id: The conversation ID to look up.
Returns:
Conversation data dict, or None if not found.
Raises:
httpx.HTTPStatusError: If the API returns a non-2xx status.
"""
resp = await self.client.get(
'/api/v1/app-conversations',
params={'ids': [conversation_id]},
headers={'Authorization': f'Bearer {api_key}'},
)
_raise_with_body(resp)
conversations = resp.json()
return conversations[0] if conversations else None
async def close(self) -> None:
"""Close the underlying HTTP client."""
await self.client.aclose()

View File

@@ -0,0 +1,77 @@
"""SQLAlchemy models for automations and automation runs.
Stub for Task 1 (Data Foundation). These models will be replaced when Task 1
is merged into automations-phase1.
"""
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Integer,
String,
Text,
text,
)
from sqlalchemy.orm import relationship
from sqlalchemy.types import JSON
from storage.base import Base
class Automation(Base):
__tablename__ = 'automations'
id = Column(String, primary_key=True)
user_id = Column(String, nullable=False, index=True)
org_id = Column(String, nullable=True, index=True)
name = Column(String, nullable=False)
enabled = Column(Boolean, nullable=False, server_default=text('true'))
config = Column(JSON, nullable=False)
trigger_type = Column(String, nullable=False)
file_store_key = Column(String, nullable=False)
last_triggered_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(
DateTime(timezone=True),
nullable=False,
server_default=text('CURRENT_TIMESTAMP'),
)
updated_at = Column(
DateTime(timezone=True),
nullable=False,
server_default=text('CURRENT_TIMESTAMP'),
)
runs = relationship('AutomationRun', back_populates='automation')
class AutomationRun(Base):
__tablename__ = 'automation_runs'
id = Column(String, primary_key=True)
automation_id = Column(
String, ForeignKey('automations.id', ondelete='CASCADE'), nullable=False
)
event_id = Column(Integer, ForeignKey('automation_events.id'), nullable=True)
conversation_id = Column(String, nullable=True)
status = Column(String, nullable=False, server_default=text("'PENDING'"))
claimed_by = Column(String, nullable=True)
claimed_at = Column(DateTime(timezone=True), nullable=True)
heartbeat_at = Column(DateTime(timezone=True), nullable=True)
retry_count = Column(Integer, nullable=False, server_default=text('0'))
max_retries = Column(Integer, nullable=False, server_default=text('3'))
next_retry_at = Column(DateTime(timezone=True), nullable=True)
event_payload = Column(JSON, nullable=True)
error_detail = Column(Text, nullable=True)
started_at = Column(DateTime(timezone=True), nullable=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(
DateTime(timezone=True),
nullable=False,
server_default=text('CURRENT_TIMESTAMP'),
)
automation = relationship('Automation', back_populates='runs')

View File

@@ -0,0 +1,27 @@
"""SQLAlchemy model for automation events (the inbox).
Stub for Task 1 (Data Foundation). This model will be replaced when Task 1
is merged into automations-phase1.
"""
from sqlalchemy import Column, DateTime, Integer, String, Text, text
from sqlalchemy.types import JSON
from storage.base import Base
class AutomationEvent(Base):
__tablename__ = 'automation_events'
id = Column(Integer, primary_key=True, autoincrement=True)
source_type = Column(String, nullable=False)
payload = Column(JSON, nullable=False)
metadata_ = Column('metadata', JSON, nullable=True)
dedup_key = Column(String, nullable=False, unique=True)
status = Column(String, nullable=False, server_default=text("'NEW'"))
error_detail = Column(Text, nullable=True)
created_at = Column(
DateTime(timezone=True),
nullable=False,
server_default=text('CURRENT_TIMESTAMP'),
)
processed_at = Column(DateTime(timezone=True), nullable=True)

View File

@@ -29,15 +29,6 @@ KEY_VERIFICATION_TIMEOUT = 5.0
# A very large number to represent "unlimited" until LiteLLM fixes their unlimited update bug.
UNLIMITED_BUDGET_SETTING = 1000000000.0
try:
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', 0.0))
if DEFAULT_INITIAL_BUDGET < 0:
raise ValueError(
f'DEFAULT_INITIAL_BUDGET must be non-negative, got {DEFAULT_INITIAL_BUDGET}'
)
except ValueError as e:
raise ValueError(f'Invalid DEFAULT_INITIAL_BUDGET environment variable: {e}') from e
def get_openhands_cloud_key_alias(keycloak_user_id: str, org_id: str) -> str:
"""Generate the key alias for OpenHands Cloud managed keys."""
@@ -110,7 +101,7 @@ class LiteLlmManager:
) as client:
# Check if team already exists and get its budget
# New users joining existing orgs should inherit the team's budget
team_budget: float = DEFAULT_INITIAL_BUDGET
team_budget = 0.0
try:
existing_team = await LiteLlmManager._get_team(client, org_id)
if existing_team:

View File

@@ -47,7 +47,6 @@ class Org(Base): # type: ignore
conversation_expiration = Column(Integer, nullable=True)
condenser_max_size = Column(Integer, nullable=True)
byor_export_enabled = Column(Boolean, nullable=False, default=False)
sandbox_grouping_strategy = Column(String, nullable=True)
# Relationships
org_members = relationship('OrgMember', back_populates='org')

View File

@@ -28,9 +28,6 @@ class OrgMemberStore:
role_id: int,
llm_api_key: str,
status: Optional[str] = None,
llm_model: Optional[str] = None,
llm_base_url: Optional[str] = None,
max_iterations: Optional[int] = None,
) -> OrgMember:
"""Add a user to an organization with a specific role."""
async with a_session_maker() as session:
@@ -40,9 +37,6 @@ class OrgMemberStore:
role_id=role_id,
llm_api_key=llm_api_key,
status=status,
llm_model=llm_model,
llm_base_url=llm_base_url,
max_iterations=max_iterations,
)
session.add(org_member)
await session.commit()

View File

@@ -6,7 +6,6 @@ from typing import Optional
from uuid import UUID
from server.constants import (
DEFAULT_V1_ENABLED,
LITE_LLM_API_URL,
ORG_SETTINGS_VERSION,
get_default_litellm_model,
@@ -37,8 +36,6 @@ class OrgStore:
org = Org(**kwargs)
org.org_version = ORG_SETTINGS_VERSION
org.default_llm_model = get_default_litellm_model()
if org.v1_enabled is None:
org.v1_enabled = DEFAULT_V1_ENABLED
session.add(org)
await session.commit()
await session.refresh(org)

View File

@@ -117,9 +117,6 @@ class SaasSettingsStore(SettingsStore):
kwargs['llm_base_url'] = org_member.llm_base_url
if org.v1_enabled is None:
kwargs['v1_enabled'] = True
# Apply default if sandbox_grouping_strategy is None in the database
if kwargs.get('sandbox_grouping_strategy') is None:
kwargs.pop('sandbox_grouping_strategy', None)
settings = Settings(**kwargs)
return settings
@@ -190,18 +187,6 @@ class SaasSettingsStore(SettingsStore):
if hasattr(model, key):
setattr(model, key, value)
# Map Settings fields to Org fields with 'default_' prefix
# The generic loop above doesn't update these because Org uses
# 'default_llm_model' not 'llm_model', etc.
# Use exclude_unset to only update explicitly-set fields (allows clearing with null)
settings_data = item.model_dump(exclude_unset=True)
if 'llm_model' in settings_data:
org.default_llm_model = settings_data['llm_model']
if 'llm_base_url' in settings_data:
org.default_llm_base_url = settings_data['llm_base_url']
if 'max_iterations' in settings_data:
org.default_max_iterations = settings_data['max_iterations']
# Propagate LLM settings to all org members
# This ensures all members see the same LLM configuration when an admin saves
# Note: Concurrent saves by multiple admins will result in last-write-wins.

View File

@@ -25,10 +25,10 @@ class SlackConversationStore:
return result.scalar_one_or_none()
async def create_slack_conversation(
self, slack_conversation: SlackConversation
self, slack_converstion: SlackConversation
) -> None:
async with a_session_maker() as session:
await session.merge(slack_conversation)
session.merge(slack_converstion)
await session.commit()
@classmethod

View File

@@ -33,7 +33,6 @@ class User(Base): # type: ignore
email_verified = Column(Boolean, nullable=True)
git_user_name = Column(String, nullable=True)
git_user_email = Column(String, nullable=True)
sandbox_grouping_strategy = Column(String, nullable=True)
# Relationships
role = relationship('Role', back_populates='users')

View File

@@ -27,7 +27,6 @@ class UserSettings(Base): # type: ignore
)
sandbox_base_container_image = Column(String, nullable=True)
sandbox_runtime_container_image = Column(String, nullable=True)
sandbox_grouping_strategy = Column(String, nullable=True)
user_version = Column(Integer, nullable=False, default=0)
accepted_tos = Column(DateTime, nullable=True)
mcp_config = Column(JSON, nullable=True)

View File

@@ -7,7 +7,6 @@ from uuid import UUID
from server.auth.token_manager import TokenManager
from server.constants import (
DEFAULT_V1_ENABLED,
LITE_LLM_API_URL,
ORG_SETTINGS_VERSION,
PERSONAL_WORKSPACE_VERSION_TO_MODEL,
@@ -242,10 +241,6 @@ class UserStore:
if hasattr(org, key):
setattr(org, key, value)
# Apply DEFAULT_V1_ENABLED for migrated orgs if v1_enabled was not set
if org.v1_enabled is None:
org.v1_enabled = DEFAULT_V1_ENABLED
user_kwargs = UserStore.get_kwargs_from_user_settings(
decrypted_user_settings
)
@@ -897,8 +892,6 @@ class UserStore:
language='en', enable_proactive_conversation_starters=True
)
default_settings.v1_enabled = DEFAULT_V1_ENABLED
from storage.lite_llm_manager import LiteLlmManager
settings = await LiteLlmManager.create_entries(

View File

@@ -0,0 +1,562 @@
#!/usr/bin/env python3
"""
Common Room Sync
This script queries the database to count conversations created by each user,
then creates or updates a signal in Common Room for each user with their
conversation count.
"""
import asyncio
import logging
import os
import sys
import time
from datetime import UTC, datetime
from typing import Any, Dict, List, Optional, Set
import requests
from sqlalchemy import text
# Add the parent directory to the path so we can import from storage
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from server.auth.token_manager import get_keycloak_admin
from storage.database import get_engine
# Configure logging
logging.basicConfig(
level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger('common_room_sync')
# Common Room API configuration
COMMON_ROOM_API_KEY = os.environ.get('COMMON_ROOM_API_KEY')
COMMON_ROOM_DESTINATION_SOURCE_ID = os.environ.get('COMMON_ROOM_DESTINATION_SOURCE_ID')
COMMON_ROOM_API_BASE_URL = 'https://api.commonroom.io/community/v1'
# Sync configuration
BATCH_SIZE = int(os.environ.get('BATCH_SIZE', '100'))
KEYCLOAK_BATCH_SIZE = int(os.environ.get('KEYCLOAK_BATCH_SIZE', '20'))
MAX_RETRIES = int(os.environ.get('MAX_RETRIES', '3'))
INITIAL_BACKOFF_SECONDS = float(os.environ.get('INITIAL_BACKOFF_SECONDS', '1'))
MAX_BACKOFF_SECONDS = float(os.environ.get('MAX_BACKOFF_SECONDS', '60'))
BACKOFF_FACTOR = float(os.environ.get('BACKOFF_FACTOR', '2'))
RATE_LIMIT = float(os.environ.get('RATE_LIMIT', '2')) # Requests per second
class CommonRoomSyncError(Exception):
"""Base exception for Common Room sync errors."""
class DatabaseError(CommonRoomSyncError):
"""Exception for database errors."""
class CommonRoomAPIError(CommonRoomSyncError):
"""Exception for Common Room API errors."""
class KeycloakClientError(CommonRoomSyncError):
"""Exception for Keycloak client errors."""
def get_recent_conversations(minutes: int = 60) -> List[Dict[str, Any]]:
"""Get conversations created in the past N minutes.
Args:
minutes: Number of minutes to look back for new conversations.
Returns:
A list of dictionaries, each containing conversation details.
Raises:
DatabaseError: If the database query fails.
"""
try:
# Use a different syntax for the interval that works with pg8000
query = text("""
SELECT
conversation_id, user_id, title, created_at
FROM
conversation_metadata
WHERE
created_at >= NOW() - (INTERVAL '1 minute' * :minutes)
ORDER BY
created_at DESC
""")
with get_engine().connect() as connection:
result = connection.execute(query, {'minutes': minutes})
conversations = [
{
'conversation_id': row[0],
'user_id': row[1],
'title': row[2],
'created_at': row[3].isoformat() if row[3] else None,
}
for row in result
]
logger.info(
f'Retrieved {len(conversations)} conversations created in the past {minutes} minutes'
)
return conversations
except Exception as e:
logger.exception(f'Error querying recent conversations: {e}')
raise DatabaseError(f'Failed to query recent conversations: {e}')
async def get_users_from_keycloak(user_ids: Set[str]) -> Dict[str, Dict[str, Any]]:
"""Get user information from Keycloak for a set of user IDs.
Args:
user_ids: A set of user IDs to look up.
Returns:
A dictionary mapping user IDs to user information dictionaries.
Raises:
KeycloakClientError: If the Keycloak API call fails.
"""
try:
# Get Keycloak admin client
keycloak_admin = get_keycloak_admin()
# Create a dictionary to store user information
user_info_dict = {}
# Convert set to list for easier batching
user_id_list = list(user_ids)
# Process user IDs in batches
for i in range(0, len(user_id_list), KEYCLOAK_BATCH_SIZE):
batch = user_id_list[i : i + KEYCLOAK_BATCH_SIZE]
batch_tasks = []
# Create tasks for each user ID in the batch
for user_id in batch:
# Use the Keycloak admin client to get user by ID
batch_tasks.append(get_user_by_id(keycloak_admin, user_id))
# Run the batch of tasks concurrently
batch_results = await asyncio.gather(*batch_tasks, return_exceptions=True)
# Process the results
for user_id, result in zip(batch, batch_results):
if isinstance(result, Exception):
logger.warning(f'Error getting user {user_id}: {result}')
continue
if result and isinstance(result, dict):
user_info_dict[user_id] = {
'username': result.get('username'),
'email': result.get('email'),
'id': result.get('id'),
}
logger.info(
f'Retrieved information for {len(user_info_dict)} users from Keycloak'
)
return user_info_dict
except Exception as e:
error_msg = f'Error getting users from Keycloak: {e}'
logger.exception(error_msg)
raise KeycloakClientError(error_msg)
async def get_user_by_id(keycloak_admin, user_id: str) -> Optional[Dict[str, Any]]:
"""Get a user from Keycloak by ID.
Args:
keycloak_admin: The Keycloak admin client.
user_id: The user ID to look up.
Returns:
A dictionary with the user's information, or None if not found.
"""
try:
# Use the Keycloak admin client to get user by ID
user = keycloak_admin.get_user(user_id)
if user:
logger.debug(
f"Found user in Keycloak: {user.get('username')}, {user.get('email')}"
)
return user
else:
logger.warning(f'User {user_id} not found in Keycloak')
return None
except Exception as e:
logger.warning(f'Error getting user {user_id} from Keycloak: {e}')
return None
def get_user_info(
user_id: str, user_info_cache: Dict[str, Dict[str, Any]]
) -> Optional[Dict[str, str]]:
"""Get the email address and GitHub username for a user from the cache.
Args:
user_id: The user ID to look up.
user_info_cache: A dictionary mapping user IDs to user information.
Returns:
A dictionary with the user's email and username, or None if not found.
"""
# Check if the user is in the cache
if user_id in user_info_cache:
user_info = user_info_cache[user_id]
logger.debug(
f"Found user info in cache: {user_info.get('username')}, {user_info.get('email')}"
)
return user_info
else:
logger.warning(f'User {user_id} not found in user info cache')
return None
def register_user_in_common_room(
user_id: str, email: str, github_username: str
) -> Dict[str, Any]:
"""Create or update a user in Common Room.
Args:
user_id: The user ID.
email: The user's email address.
github_username: The user's GitHub username.
Returns:
The API response from Common Room.
Raises:
CommonRoomAPIError: If the Common Room API request fails.
"""
if not COMMON_ROOM_API_KEY:
raise CommonRoomAPIError('COMMON_ROOM_API_KEY environment variable not set')
if not COMMON_ROOM_DESTINATION_SOURCE_ID:
raise CommonRoomAPIError(
'COMMON_ROOM_DESTINATION_SOURCE_ID environment variable not set'
)
try:
headers = {
'Authorization': f'Bearer {COMMON_ROOM_API_KEY}',
'Content-Type': 'application/json',
}
# Create or update user in Common Room
user_data = {
'id': user_id,
'email': email,
'username': github_username,
'github': {'type': 'handle', 'value': github_username},
}
user_url = f'{COMMON_ROOM_API_BASE_URL}/source/{COMMON_ROOM_DESTINATION_SOURCE_ID}/user'
user_response = requests.post(user_url, headers=headers, json=user_data)
if user_response.status_code not in (200, 202):
logger.error(
f'Failed to create/update user in Common Room: {user_response.text}'
)
logger.error(f'Response status code: {user_response.status_code}')
raise CommonRoomAPIError(
f'Failed to create/update user: {user_response.text}'
)
logger.info(
f'Registered/updated user {user_id} (GitHub: {github_username}) in Common Room'
)
return user_response.json()
except requests.RequestException as e:
logger.exception(f'Error communicating with Common Room API: {e}')
raise CommonRoomAPIError(f'Failed to communicate with Common Room API: {e}')
def register_conversation_activity(
user_id: str,
conversation_id: str,
conversation_title: str,
created_at: datetime,
email: str,
github_username: str,
) -> Dict[str, Any]:
"""Create an activity in Common Room for a new conversation.
Args:
user_id: The user ID who created the conversation.
conversation_id: The ID of the conversation.
conversation_title: The title of the conversation.
created_at: The datetime object when the conversation was created.
email: The user's email address.
github_username: The user's GitHub username.
Returns:
The API response from Common Room.
Raises:
CommonRoomAPIError: If the Common Room API request fails.
"""
if not COMMON_ROOM_API_KEY:
raise CommonRoomAPIError('COMMON_ROOM_API_KEY environment variable not set')
if not COMMON_ROOM_DESTINATION_SOURCE_ID:
raise CommonRoomAPIError(
'COMMON_ROOM_DESTINATION_SOURCE_ID environment variable not set'
)
try:
headers = {
'Authorization': f'Bearer {COMMON_ROOM_API_KEY}',
'Content-Type': 'application/json',
}
# Format the datetime object to the expected ISO format
formatted_timestamp = (
created_at.strftime('%Y-%m-%dT%H:%M:%SZ')
if created_at
else time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
)
# Create activity for the conversation
activity_data = {
'id': f'conversation_{conversation_id}', # Use conversation ID to ensure uniqueness
'activityType': 'started_session',
'user': {
'id': user_id,
'email': email,
'github': {'type': 'handle', 'value': github_username},
'username': github_username,
},
'activityTitle': {
'type': 'text',
'value': conversation_title or 'New Conversation',
},
'content': {
'type': 'text',
'value': f'Started a new conversation: {conversation_title or "Untitled"}',
},
'timestamp': formatted_timestamp,
'url': f'https://app.all-hands.dev/conversations/{conversation_id}',
}
# Log the activity data for debugging
logger.info(f'Activity data payload: {activity_data}')
activity_url = f'{COMMON_ROOM_API_BASE_URL}/source/{COMMON_ROOM_DESTINATION_SOURCE_ID}/activity'
activity_response = requests.post(
activity_url, headers=headers, json=activity_data
)
if activity_response.status_code not in (200, 202):
logger.error(
f'Failed to create activity in Common Room: {activity_response.text}'
)
logger.error(f'Response status code: {activity_response.status_code}')
raise CommonRoomAPIError(
f'Failed to create activity: {activity_response.text}'
)
logger.info(
f'Registered conversation activity for user {user_id}, conversation {conversation_id}'
)
return activity_response.json()
except requests.RequestException as e:
logger.exception(f'Error communicating with Common Room API: {e}')
raise CommonRoomAPIError(f'Failed to communicate with Common Room API: {e}')
def retry_with_backoff(func, *args, **kwargs):
"""Retry a function with exponential backoff.
Args:
func: The function to retry.
*args: Positional arguments to pass to the function.
**kwargs: Keyword arguments to pass to the function.
Returns:
The result of the function call.
Raises:
The last exception raised by the function.
"""
backoff = INITIAL_BACKOFF_SECONDS
last_exception = None
for attempt in range(MAX_RETRIES):
try:
return func(*args, **kwargs)
except Exception as e:
last_exception = e
logger.warning(f'Attempt {attempt + 1}/{MAX_RETRIES} failed: {e}')
if attempt < MAX_RETRIES - 1:
sleep_time = min(backoff, MAX_BACKOFF_SECONDS)
logger.info(f'Retrying in {sleep_time:.2f} seconds...')
time.sleep(sleep_time)
backoff *= BACKOFF_FACTOR
else:
logger.exception(f'All {MAX_RETRIES} attempts failed')
raise last_exception
async def retry_with_backoff_async(func, *args, **kwargs):
"""Retry an async function with exponential backoff.
Args:
func: The async function to retry.
*args: Positional arguments to pass to the function.
**kwargs: Keyword arguments to pass to the function.
Returns:
The result of the function call.
Raises:
The last exception raised by the function.
"""
backoff = INITIAL_BACKOFF_SECONDS
last_exception = None
for attempt in range(MAX_RETRIES):
try:
return await func(*args, **kwargs)
except Exception as e:
last_exception = e
logger.warning(f'Attempt {attempt + 1}/{MAX_RETRIES} failed: {e}')
if attempt < MAX_RETRIES - 1:
sleep_time = min(backoff, MAX_BACKOFF_SECONDS)
logger.info(f'Retrying in {sleep_time:.2f} seconds...')
await asyncio.sleep(sleep_time)
backoff *= BACKOFF_FACTOR
else:
logger.exception(f'All {MAX_RETRIES} attempts failed')
raise last_exception
async def async_sync_recent_conversations_to_common_room(minutes: int = 60):
"""Async main function to sync recent conversations to Common Room.
Args:
minutes: Number of minutes to look back for new conversations.
"""
logger.info(
f'Starting Common Room recent conversations sync (past {minutes} minutes)'
)
stats = {
'total_conversations': 0,
'registered_users': 0,
'registered_activities': 0,
'errors': 0,
'missing_user_info': 0,
}
try:
# Get conversations created in the past N minutes
recent_conversations = retry_with_backoff(get_recent_conversations, minutes)
stats['total_conversations'] = len(recent_conversations)
logger.info(f'Processing {len(recent_conversations)} recent conversations')
if not recent_conversations:
logger.info('No recent conversations found, exiting')
return
# Extract all unique user IDs
user_ids = {conv['user_id'] for conv in recent_conversations if conv['user_id']}
# Get user information for all users in batches
user_info_cache = await retry_with_backoff_async(
get_users_from_keycloak, user_ids
)
# Track registered users to avoid duplicate registrations
registered_users = set()
# Process each conversation
for conversation in recent_conversations:
conversation_id = conversation['conversation_id']
user_id = conversation['user_id']
title = conversation['title']
created_at = conversation[
'created_at'
] # This might be a string or datetime object
try:
# Get user info from cache
user_info = get_user_info(user_id, user_info_cache)
if not user_info:
logger.warning(
f'Could not find user info for user {user_id}, skipping conversation {conversation_id}'
)
stats['missing_user_info'] += 1
continue
email = user_info['email']
github_username = user_info['username']
if not email:
logger.warning(
f'User {user_id} has no email, skipping conversation {conversation_id}'
)
stats['errors'] += 1
continue
# Register user in Common Room if not already registered in this run
if user_id not in registered_users:
register_user_in_common_room(user_id, email, github_username)
registered_users.add(user_id)
stats['registered_users'] += 1
# If created_at is a string, parse it to a datetime object
# If it's already a datetime object, use it as is
# If it's None, use current time
created_at_datetime = (
created_at
if isinstance(created_at, datetime)
else datetime.fromisoformat(created_at.replace('Z', '+00:00'))
if created_at
else datetime.now(UTC)
)
# Register conversation activity with email and github username
register_conversation_activity(
user_id,
conversation_id,
title,
created_at_datetime,
email,
github_username,
)
stats['registered_activities'] += 1
# Sleep to respect rate limit
await asyncio.sleep(1 / RATE_LIMIT)
except Exception as e:
logger.exception(
f'Error processing conversation {conversation_id} for user {user_id}: {e}'
)
stats['errors'] += 1
except Exception as e:
logger.exception(f'Sync failed: {e}')
raise
finally:
logger.info(f'Sync completed. Stats: {stats}')
def sync_recent_conversations_to_common_room(minutes: int = 60):
"""Main function to sync recent conversations to Common Room.
Args:
minutes: Number of minutes to look back for new conversations.
"""
# Run the async function in the event loop
asyncio.run(async_sync_recent_conversations_to_common_room(minutes))
if __name__ == '__main__':
# Default to looking back 60 minutes for new conversations
minutes = int(os.environ.get('SYNC_MINUTES', '60'))
sync_recent_conversations_to_common_room(minutes)

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""
Test script for Common Room conversation count sync.
This script tests the functionality of the Common Room sync script
without making any API calls to Common Room or database connections.
"""
import os
import sys
import unittest
from unittest.mock import MagicMock, patch
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sync.common_room_sync import (
retry_with_backoff,
)
class TestCommonRoomSync(unittest.TestCase):
"""Test cases for Common Room sync functionality."""
def test_retry_with_backoff(self):
"""Test the retry_with_backoff function."""
# Mock function that succeeds on the second attempt
mock_func = MagicMock(
side_effect=[Exception('First attempt failed'), 'success']
)
# Set environment variables for testing
with patch.dict(
os.environ,
{
'MAX_RETRIES': '3',
'INITIAL_BACKOFF_SECONDS': '0.01',
'BACKOFF_FACTOR': '2',
'MAX_BACKOFF_SECONDS': '1',
},
):
result = retry_with_backoff(mock_func, 'arg1', 'arg2', kwarg1='kwarg1')
# Check that the function was called twice
self.assertEqual(mock_func.call_count, 2)
# Check that the function was called with the correct arguments
mock_func.assert_called_with('arg1', 'arg2', kwarg1='kwarg1')
# Check that the function returned the expected result
self.assertEqual(result, 'success')
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Test script to verify the conversation count query.
This script tests the database query to count conversations by user,
without making any API calls to Common Room.
"""
import os
import sys
from sqlalchemy import text
# Add the parent directory to the path so we can import from storage
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from storage.database import get_engine
def test_conversation_count_query():
"""Test the query to count conversations by user."""
try:
# Query to count conversations by user
count_query = text("""
SELECT
user_id, COUNT(*) as conversation_count
FROM
conversation_metadata
GROUP BY
user_id
""")
engine = get_engine()
with engine.connect() as connection:
count_result = connection.execute(count_query)
user_counts = [
{'user_id': row[0], 'conversation_count': row[1]}
for row in count_result
]
print(f'Found {len(user_counts)} users with conversations')
# Print the first 5 results
for i, user_data in enumerate(user_counts[:5]):
print(
f"User {i+1}: {user_data['user_id']} - {user_data['conversation_count']} conversations"
)
# Test the user_entity query for the first user (if any)
if user_counts:
first_user_id = user_counts[0]['user_id']
user_query = text("""
SELECT username, email, id
FROM user_entity
WHERE id = :user_id
""")
with engine.connect() as connection:
user_result = connection.execute(user_query, {'user_id': first_user_id})
user_row = user_result.fetchone()
if user_row:
print(f'\nUser details for {first_user_id}:')
print(f' GitHub Username: {user_row[0]}')
print(f' Email: {user_row[1]}')
print(f' ID: {user_row[2]}')
else:
print(
f'\nNo user details found for {first_user_id} in user_entity table'
)
print('\nTest completed successfully')
except Exception as e:
print(f'Error: {str(e)}')
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == '__main__':
test_conversation_count_query()

View File

@@ -28,7 +28,6 @@ from storage.org import Org
from storage.org_invitation import OrgInvitation # noqa: F401
from storage.org_member import OrgMember
from storage.role import Role
from storage.slack_conversation import SlackConversation # noqa: F401
from storage.stored_conversation_metadata import StoredConversationMetadata
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,

View File

@@ -0,0 +1,68 @@
"""Shared fixtures for services tests.
Note: We pre-load ``storage`` as a namespace package to avoid the heavy
``storage/__init__.py`` that imports the entire enterprise model graph.
This must happen *before* any ``from storage.…`` import.
"""
import contextlib
import sys
import types
# Prevent storage/__init__.py from loading the full model graph.
# We only need the lightweight automation models for these tests.
if 'storage' not in sys.modules:
import pathlib
_storage_dir = str(pathlib.Path(__file__).resolve().parents[3] / 'storage')
_mod = types.ModuleType('storage')
_mod.__path__ = [_storage_dir]
sys.modules['storage'] = _mod
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from storage.automation import Automation, AutomationRun # noqa: F401
from storage.automation_event import AutomationEvent # noqa: F401
from storage.base import Base
@pytest.fixture
async def async_engine():
"""Create an async SQLite engine for testing."""
engine = create_async_engine(
'sqlite+aiosqlite:///:memory:',
poolclass=StaticPool,
connect_args={'check_same_thread': False},
echo=False,
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
async def async_session_factory(async_engine):
"""Create an async session factory that yields context-managed sessions."""
factory = async_sessionmaker(
bind=async_engine,
class_=AsyncSession,
expire_on_commit=False,
)
@contextlib.asynccontextmanager
async def _session_ctx():
async with factory() as session:
yield session
return _session_ctx
@pytest.fixture
async def async_session(async_session_factory):
"""Create a single async session for testing."""
async with async_session_factory() as session:
yield session

View File

@@ -0,0 +1,624 @@
"""Tests for the automation executor.
Uses real SQLite database operations for event processing, run claiming,
and stale run recovery. HTTP calls to the V1 API are mocked.
"""
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, patch
from uuid import uuid4
import pytest
from services.automation_executor import (
_mark_run_failed,
claim_and_execute_runs,
find_matching_automations,
is_terminal,
process_new_events,
recover_stale_runs,
utc_now,
)
from sqlalchemy import select
from storage.automation import Automation, AutomationRun
from storage.automation_event import AutomationEvent
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_automation(
automation_id: str = 'auto-1',
user_id: str = 'user-1',
enabled: bool = True,
trigger_type: str = 'cron',
name: str = 'Test Automation',
) -> Automation:
return Automation(
id=automation_id,
user_id=user_id,
org_id='org-1',
name=name,
enabled=enabled,
config={'triggers': {'cron': {'schedule': '0 9 * * 5'}}},
trigger_type=trigger_type,
file_store_key=f'automations/{automation_id}/script.py',
)
def make_event(
source_type: str = 'cron',
payload: dict | None = None,
status: str = 'NEW',
dedup_key: str | None = None,
) -> AutomationEvent:
return AutomationEvent(
source_type=source_type,
payload=payload or {'automation_id': 'auto-1'},
dedup_key=dedup_key or f'dedup-{uuid4().hex[:8]}',
status=status,
created_at=utc_now(),
)
def make_run(
run_id: str | None = None,
automation_id: str = 'auto-1',
status: str = 'PENDING',
claimed_by: str | None = None,
heartbeat_at: datetime | None = None,
retry_count: int = 0,
max_retries: int = 3,
next_retry_at: datetime | None = None,
) -> AutomationRun:
return AutomationRun(
id=run_id or uuid4().hex,
automation_id=automation_id,
status=status,
claimed_by=claimed_by,
heartbeat_at=heartbeat_at,
retry_count=retry_count,
max_retries=max_retries,
next_retry_at=next_retry_at,
event_payload={'automation_id': automation_id},
created_at=utc_now(),
)
# ---------------------------------------------------------------------------
# find_matching_automations
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_find_matching_automations_cron_event(async_session):
"""Cron events match by automation_id in payload."""
automation = make_automation()
async_session.add(automation)
await async_session.commit()
event = make_event(source_type='cron', payload={'automation_id': 'auto-1'})
async_session.add(event)
await async_session.commit()
result = await find_matching_automations(async_session, event)
assert len(result) == 1
assert result[0].id == 'auto-1'
@pytest.mark.asyncio
async def test_find_matching_automations_manual_event(async_session):
"""Manual events also match by automation_id in payload."""
automation = make_automation()
async_session.add(automation)
await async_session.commit()
event = make_event(source_type='manual', payload={'automation_id': 'auto-1'})
async_session.add(event)
await async_session.commit()
result = await find_matching_automations(async_session, event)
assert len(result) == 1
assert result[0].id == 'auto-1'
@pytest.mark.asyncio
async def test_find_matching_automations_disabled_automation(async_session):
"""Disabled automations are not matched."""
automation = make_automation(enabled=False)
async_session.add(automation)
await async_session.commit()
event = make_event(payload={'automation_id': 'auto-1'})
async_session.add(event)
await async_session.commit()
result = await find_matching_automations(async_session, event)
assert len(result) == 0
@pytest.mark.asyncio
async def test_find_matching_automations_missing_automation_id(async_session):
"""Events without automation_id in payload return empty list."""
event = make_event(payload={'something_else': 'value'})
async_session.add(event)
await async_session.commit()
result = await find_matching_automations(async_session, event)
assert len(result) == 0
@pytest.mark.asyncio
async def test_find_matching_automations_nonexistent_automation(async_session):
"""Events referencing a non-existent automation return empty list."""
event = make_event(payload={'automation_id': 'nonexistent'})
async_session.add(event)
await async_session.commit()
result = await find_matching_automations(async_session, event)
assert len(result) == 0
@pytest.mark.asyncio
async def test_find_matching_automations_unknown_source_type(async_session):
"""Unknown source types return empty list."""
event = make_event(source_type='unknown', payload={'automation_id': 'auto-1'})
async_session.add(event)
await async_session.commit()
result = await find_matching_automations(async_session, event)
assert len(result) == 0
# ---------------------------------------------------------------------------
# process_new_events
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_process_new_events_creates_runs(async_session):
"""Processing NEW events creates PENDING runs and marks events PROCESSED."""
automation = make_automation()
event = make_event(payload={'automation_id': 'auto-1'})
async_session.add_all([automation, event])
await async_session.commit()
count = await process_new_events(async_session)
assert count == 1
# Event should be PROCESSED
await async_session.refresh(event)
assert event.status == 'PROCESSED'
assert event.processed_at is not None
# A run should have been created
runs = (await async_session.execute(select(AutomationRun))).scalars().all()
assert len(runs) == 1
assert runs[0].automation_id == 'auto-1'
assert runs[0].status == 'PENDING'
assert runs[0].event_payload == {'automation_id': 'auto-1'}
@pytest.mark.asyncio
async def test_process_new_events_no_match(async_session):
"""Events with no matching automation are marked NO_MATCH."""
event = make_event(payload={'automation_id': 'nonexistent'})
async_session.add(event)
await async_session.commit()
count = await process_new_events(async_session)
assert count == 1
await async_session.refresh(event)
assert event.status == 'NO_MATCH'
assert event.processed_at is not None
# No runs created
runs = (await async_session.execute(select(AutomationRun))).scalars().all()
assert len(runs) == 0
@pytest.mark.asyncio
async def test_process_new_events_skips_processed(async_session):
"""Already processed events are not re-processed."""
event = make_event(status='PROCESSED')
async_session.add(event)
await async_session.commit()
count = await process_new_events(async_session)
assert count == 0
@pytest.mark.asyncio
async def test_process_new_events_multiple_events(async_session):
"""Multiple NEW events are processed in one batch."""
auto1 = make_automation(automation_id='auto-1')
auto2 = make_automation(automation_id='auto-2', name='Auto 2')
event1 = make_event(payload={'automation_id': 'auto-1'}, dedup_key='dedup-1')
event2 = make_event(payload={'automation_id': 'auto-2'}, dedup_key='dedup-2')
event3 = make_event(payload={'automation_id': 'nonexistent'}, dedup_key='dedup-3')
async_session.add_all([auto1, auto2, event1, event2, event3])
await async_session.commit()
count = await process_new_events(async_session)
assert count == 3
# Two runs created (for auto-1 and auto-2), none for nonexistent
runs = (await async_session.execute(select(AutomationRun))).scalars().all()
assert len(runs) == 2
await async_session.refresh(event1)
await async_session.refresh(event2)
await async_session.refresh(event3)
assert event1.status == 'PROCESSED'
assert event2.status == 'PROCESSED'
assert event3.status == 'NO_MATCH'
# ---------------------------------------------------------------------------
# claim_and_execute_runs
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_claim_and_execute_runs_claims_pending(
async_session, async_session_factory
):
"""Claims a PENDING run and transitions to RUNNING."""
automation = make_automation()
run = make_run(run_id='run-1')
async_session.add_all([automation, run])
await async_session.commit()
api_client = AsyncMock()
with patch('services.automation_executor.execute_run', new_callable=AsyncMock):
claimed = await claim_and_execute_runs(
async_session, 'executor-test-1', api_client, async_session_factory
)
assert claimed is True
await async_session.refresh(run)
assert run.status == 'RUNNING'
assert run.claimed_by == 'executor-test-1'
assert run.claimed_at is not None
assert run.heartbeat_at is not None
assert run.started_at is not None
@pytest.mark.asyncio
async def test_claim_and_execute_runs_no_pending(async_session, async_session_factory):
"""Returns False when no PENDING runs exist."""
api_client = AsyncMock()
claimed = await claim_and_execute_runs(
async_session, 'executor-test-1', api_client, async_session_factory
)
assert claimed is False
@pytest.mark.asyncio
async def test_claim_and_execute_runs_respects_next_retry_at(
async_session, async_session_factory
):
"""Runs with future next_retry_at are not claimed."""
automation = make_automation()
run = make_run(
run_id='run-retry',
next_retry_at=utc_now() + timedelta(hours=1),
)
async_session.add_all([automation, run])
await async_session.commit()
api_client = AsyncMock()
claimed = await claim_and_execute_runs(
async_session, 'executor-test-1', api_client, async_session_factory
)
assert claimed is False
@pytest.mark.asyncio
async def test_claim_and_execute_runs_past_retry_at(
async_session, async_session_factory
):
"""Runs with past next_retry_at are claimable."""
automation = make_automation()
run = make_run(
run_id='run-retry-past',
next_retry_at=utc_now() - timedelta(minutes=5),
)
async_session.add_all([automation, run])
await async_session.commit()
api_client = AsyncMock()
with patch('services.automation_executor.execute_run', new_callable=AsyncMock):
claimed = await claim_and_execute_runs(
async_session, 'executor-test-1', api_client, async_session_factory
)
assert claimed is True
@pytest.mark.asyncio
async def test_claim_skips_running_runs(async_session, async_session_factory):
"""RUNNING runs are not claimed."""
automation = make_automation()
run = make_run(run_id='run-running', status='RUNNING', claimed_by='other-executor')
async_session.add_all([automation, run])
await async_session.commit()
api_client = AsyncMock()
claimed = await claim_and_execute_runs(
async_session, 'executor-test-1', api_client, async_session_factory
)
assert claimed is False
# ---------------------------------------------------------------------------
# recover_stale_runs
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_recover_stale_runs_recovers_stale(async_session):
"""RUNNING runs with expired heartbeats are recovered to PENDING."""
automation = make_automation()
stale_run = make_run(
run_id='stale-1',
status='RUNNING',
claimed_by='crashed-executor',
heartbeat_at=utc_now() - timedelta(minutes=10),
retry_count=0,
)
async_session.add_all([automation, stale_run])
await async_session.commit()
count = await recover_stale_runs(async_session)
assert count >= 1
await async_session.refresh(stale_run)
assert stale_run.status == 'PENDING'
assert stale_run.claimed_by is None
assert stale_run.retry_count == 1
assert stale_run.next_retry_at is not None
@pytest.mark.asyncio
async def test_recover_stale_runs_ignores_fresh(async_session):
"""RUNNING runs with recent heartbeats are not recovered."""
automation = make_automation()
fresh_run = make_run(
run_id='fresh-1',
status='RUNNING',
claimed_by='active-executor',
heartbeat_at=utc_now() - timedelta(seconds=30),
)
async_session.add_all([automation, fresh_run])
await async_session.commit()
count = await recover_stale_runs(async_session)
assert count == 0
await async_session.refresh(fresh_run)
assert fresh_run.status == 'RUNNING'
assert fresh_run.claimed_by == 'active-executor'
@pytest.mark.asyncio
async def test_recover_stale_runs_ignores_pending(async_session):
"""PENDING runs are not affected by recovery."""
automation = make_automation()
pending_run = make_run(run_id='pending-1', status='PENDING')
async_session.add_all([automation, pending_run])
await async_session.commit()
count = await recover_stale_runs(async_session)
assert count == 0
await async_session.refresh(pending_run)
assert pending_run.status == 'PENDING'
@pytest.mark.asyncio
async def test_recover_stale_runs_increments_retry_count(async_session):
"""Recovery increments the retry_count."""
automation = make_automation()
stale_run = make_run(
run_id='stale-retry',
status='RUNNING',
claimed_by='old-executor',
heartbeat_at=utc_now() - timedelta(minutes=10),
retry_count=2,
)
async_session.add_all([automation, stale_run])
await async_session.commit()
await recover_stale_runs(async_session)
await async_session.refresh(stale_run)
assert stale_run.retry_count == 3
# ---------------------------------------------------------------------------
# _mark_run_failed (error handling)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_mark_run_failed_retries(async_session_factory):
"""Failed runs with retries left return to PENDING."""
async with async_session_factory() as session:
automation = make_automation()
run = make_run(run_id='fail-retry', retry_count=0, max_retries=3)
session.add_all([automation, run])
await session.commit()
async with async_session_factory() as session:
run_obj = await session.get(AutomationRun, 'fail-retry')
await _mark_run_failed(run_obj, 'API error', async_session_factory)
async with async_session_factory() as session:
run_obj = await session.get(AutomationRun, 'fail-retry')
assert run_obj.status == 'PENDING'
assert run_obj.retry_count == 1
assert run_obj.error_detail == 'API error'
assert run_obj.next_retry_at is not None
assert run_obj.claimed_by is None
@pytest.mark.asyncio
async def test_mark_run_failed_dead_letter(async_session_factory):
"""Failed runs that exceed max_retries go to DEAD_LETTER."""
async with async_session_factory() as session:
automation = make_automation()
run = make_run(run_id='fail-dead', retry_count=2, max_retries=3)
session.add_all([automation, run])
await session.commit()
async with async_session_factory() as session:
run_obj = await session.get(AutomationRun, 'fail-dead')
await _mark_run_failed(run_obj, 'Final failure', async_session_factory)
async with async_session_factory() as session:
run_obj = await session.get(AutomationRun, 'fail-dead')
assert run_obj.status == 'DEAD_LETTER'
assert run_obj.retry_count == 3
assert run_obj.error_detail == 'Final failure'
assert run_obj.completed_at is not None
# ---------------------------------------------------------------------------
# is_terminal
# ---------------------------------------------------------------------------
def test_is_terminal_stopped():
assert is_terminal({'status': 'STOPPED'}) is True
def test_is_terminal_error():
assert is_terminal({'status': 'ERROR'}) is True
def test_is_terminal_completed():
assert is_terminal({'status': 'COMPLETED'}) is True
def test_is_terminal_cancelled():
assert is_terminal({'status': 'CANCELLED'}) is True
def test_is_terminal_running():
assert is_terminal({'status': 'RUNNING'}) is False
def test_is_terminal_empty():
assert is_terminal({}) is False
def test_is_terminal_case_insensitive():
assert is_terminal({'status': 'stopped'}) is True
assert is_terminal({'status': 'Completed'}) is True
# ---------------------------------------------------------------------------
# find_matching_automations — None payload
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_find_matching_automations_none_payload(async_session):
"""Events with None payload return empty list (data corruption guard)."""
event = make_event(source_type='cron')
event.payload = None
async_session.add(event)
await async_session.commit()
result = await find_matching_automations(async_session, event)
assert result == []
# ---------------------------------------------------------------------------
# Integration: event → run creation → claim
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_integration_event_to_run_to_claim(
async_session_factory,
):
"""Full flow: create event + automation → process_new_events → claim_and_execute_runs.
Uses a real SQLite database; only the external API client is mocked.
"""
# 1. Seed an automation and a NEW event
async with async_session_factory() as session:
automation = make_automation(automation_id='integ-auto')
event = make_event(
source_type='cron',
payload={'automation_id': 'integ-auto'},
dedup_key='integ-dedup',
)
session.add_all([automation, event])
await session.commit()
event_id = event.id
# 2. Process inbox — should match and create a PENDING run
async with async_session_factory() as session:
processed = await process_new_events(session)
assert processed == 1
# Verify event is PROCESSED and run was created
async with async_session_factory() as session:
evt = await session.get(AutomationEvent, event_id)
assert evt.status == 'PROCESSED'
runs = (await session.execute(select(AutomationRun))).scalars().all()
assert len(runs) == 1
run = runs[0]
assert run.automation_id == 'integ-auto'
assert run.status == 'PENDING'
assert run.event_payload == {'automation_id': 'integ-auto'}
# 3. Claim the run — mock execute_run to avoid real API calls
api_client = AsyncMock()
with patch('services.automation_executor.execute_run', new_callable=AsyncMock):
async with async_session_factory() as session:
claimed = await claim_and_execute_runs(
session, 'executor-integ', api_client, async_session_factory
)
assert claimed is True
# 4. Verify the run moved to RUNNING with correct executor
async with async_session_factory() as session:
runs = (await session.execute(select(AutomationRun))).scalars().all()
assert len(runs) == 1
run = runs[0]
assert run.status == 'RUNNING'
assert run.claimed_by == 'executor-integ'
assert run.started_at is not None
assert run.heartbeat_at is not None

View File

@@ -0,0 +1,185 @@
"""Tests for OpenHandsAPIClient with mocked HTTP responses."""
import base64
import httpx
import pytest
from services.openhands_api_client import OpenHandsAPIClient
@pytest.fixture
def api_client():
client = OpenHandsAPIClient(base_url='http://test-server:3000')
yield client
# close handled in tests that need it
# ---------------------------------------------------------------------------
# start_conversation
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_start_conversation_sends_correct_request(api_client, respx_mock):
"""start_conversation sends properly formatted POST with auth header."""
automation_file = b'print("hello")'
expected_b64 = base64.b64encode(automation_file).decode()
route = respx_mock.post('http://test-server:3000/api/v1/app-conversations').mock(
return_value=httpx.Response(
200,
json={
'app_conversation_id': 'conv-123',
'status': 'RUNNING',
},
)
)
result = await api_client.start_conversation(
api_key='sk-oh-test123',
automation_file=automation_file,
title='Test Automation',
event_payload={'automation_id': 'auto-1'},
)
assert route.called
request = route.calls[0].request
assert request.headers['Authorization'] == 'Bearer sk-oh-test123'
import json
body = json.loads(request.content)
assert body['automation_file'] == expected_b64
assert body['trigger'] == 'automation'
assert body['title'] == 'Test Automation'
assert body['event_payload'] == {'automation_id': 'auto-1'}
assert result == {'app_conversation_id': 'conv-123', 'status': 'RUNNING'}
@pytest.mark.asyncio
async def test_start_conversation_without_event_payload(api_client, respx_mock):
"""start_conversation works with event_payload=None."""
respx_mock.post('http://test-server:3000/api/v1/app-conversations').mock(
return_value=httpx.Response(200, json={'app_conversation_id': 'conv-456'})
)
result = await api_client.start_conversation(
api_key='sk-oh-test',
automation_file=b'code',
title='Test',
event_payload=None,
)
assert result['app_conversation_id'] == 'conv-456'
@pytest.mark.asyncio
async def test_start_conversation_http_error(api_client, respx_mock):
"""start_conversation raises on HTTP errors."""
respx_mock.post('http://test-server:3000/api/v1/app-conversations').mock(
return_value=httpx.Response(500, json={'error': 'Internal Server Error'})
)
with pytest.raises(httpx.HTTPStatusError) as exc_info:
await api_client.start_conversation(
api_key='sk-oh-test',
automation_file=b'code',
title='Test',
)
assert exc_info.value.response.status_code == 500
@pytest.mark.asyncio
async def test_start_conversation_auth_error(api_client, respx_mock):
"""start_conversation raises on 401 Unauthorized."""
respx_mock.post('http://test-server:3000/api/v1/app-conversations').mock(
return_value=httpx.Response(401, json={'error': 'Unauthorized'})
)
with pytest.raises(httpx.HTTPStatusError) as exc_info:
await api_client.start_conversation(
api_key='bad-key',
automation_file=b'code',
title='Test',
)
assert exc_info.value.response.status_code == 401
# ---------------------------------------------------------------------------
# get_conversation
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_conversation_returns_data(api_client, respx_mock):
"""get_conversation returns the first conversation from the list."""
respx_mock.get('http://test-server:3000/api/v1/app-conversations').mock(
return_value=httpx.Response(
200,
json=[
{
'conversation_id': 'conv-123',
'status': 'RUNNING',
'title': 'My Automation',
}
],
)
)
result = await api_client.get_conversation('sk-oh-test', 'conv-123')
assert result is not None
assert result['conversation_id'] == 'conv-123'
assert result['status'] == 'RUNNING'
@pytest.mark.asyncio
async def test_get_conversation_returns_none_when_empty(api_client, respx_mock):
"""get_conversation returns None when API returns empty list."""
respx_mock.get('http://test-server:3000/api/v1/app-conversations').mock(
return_value=httpx.Response(200, json=[])
)
result = await api_client.get_conversation('sk-oh-test', 'nonexistent')
assert result is None
@pytest.mark.asyncio
async def test_get_conversation_sends_auth_header(api_client, respx_mock):
"""get_conversation sends the correct authorization header."""
route = respx_mock.get('http://test-server:3000/api/v1/app-conversations').mock(
return_value=httpx.Response(200, json=[])
)
await api_client.get_conversation('sk-oh-mykey', 'conv-1')
assert route.called
request = route.calls[0].request
assert request.headers['Authorization'] == 'Bearer sk-oh-mykey'
@pytest.mark.asyncio
async def test_get_conversation_http_error(api_client, respx_mock):
"""get_conversation raises on HTTP errors."""
respx_mock.get('http://test-server:3000/api/v1/app-conversations').mock(
return_value=httpx.Response(503, text='Service Unavailable')
)
with pytest.raises(httpx.HTTPStatusError):
await api_client.get_conversation('sk-oh-test', 'conv-1')
# ---------------------------------------------------------------------------
# close
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_close(api_client):
"""close() shuts down the HTTP client without errors."""
await api_client.close()

View File

@@ -10,9 +10,6 @@ from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4
import pytest
from server.utils.saas_app_conversation_info_injector import (
SaasSQLAppConversationInfoService,
)
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
@@ -20,6 +17,9 @@ from storage.base import Base
from storage.org import Org
from storage.user import User
from enterprise.server.utils.saas_app_conversation_info_injector import (
SaasSQLAppConversationInfoService,
)
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationInfo,
)
@@ -663,330 +663,3 @@ class TestSaasSQLAppConversationInfoServiceAdminContext:
admin_page = await admin_service.search_app_conversation_info()
assert len(admin_page.items) == 5
class TestSaasSQLAppConversationInfoServiceWebhookFallback:
"""Test suite for webhook callback fallback using info.created_by_user_id."""
@pytest.mark.asyncio
async def test_save_with_admin_context_uses_created_by_user_id_fallback(
self,
async_session_with_users: AsyncSession,
):
"""Test that save_app_conversation_info uses info.created_by_user_id when user_context returns None.
This is the key fix for SDK-created conversations: when the webhook endpoint
uses ADMIN context (user_id=None), the service should fall back to using
the created_by_user_id from the AppConversationInfo object.
"""
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
from openhands.app_server.user.specifiy_user_context import ADMIN
# Arrange: Create service with ADMIN context (user_id=None)
admin_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=ADMIN,
)
# Create conversation info with created_by_user_id set (as would come from sandbox_info)
conv_id = uuid4()
conv_info = AppConversationInfo(
id=conv_id,
created_by_user_id=str(USER1_ID), # This should be used as fallback
sandbox_id='sandbox_webhook_test',
title='Webhook Created Conversation',
)
# Act: Save using ADMIN context
await admin_service.save_app_conversation_info(conv_info)
# Assert: SAAS metadata should be created with user_id from info.created_by_user_id
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(conv_id)
)
result = await async_session_with_users.execute(saas_query)
saas_metadata = result.scalar_one_or_none()
assert saas_metadata is not None, 'SAAS metadata should be created'
assert (
saas_metadata.user_id == USER1_ID
), 'user_id should match info.created_by_user_id'
assert saas_metadata.org_id == ORG1_ID, 'org_id should match user current org'
@pytest.mark.asyncio
async def test_save_with_admin_context_no_user_id_skips_saas_metadata(
self,
async_session_with_users: AsyncSession,
):
"""Test that save_app_conversation_info skips SAAS metadata when both user_context and info have no user_id."""
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
from openhands.app_server.user.specifiy_user_context import ADMIN
# Arrange: Create service with ADMIN context (user_id=None)
admin_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=ADMIN,
)
# Create conversation info without created_by_user_id
conv_id = uuid4()
conv_info = AppConversationInfo(
id=conv_id,
created_by_user_id=None, # No user_id available
sandbox_id='sandbox_no_user',
title='No User Conversation',
)
# Act: Save using ADMIN context with no user_id fallback
await admin_service.save_app_conversation_info(conv_info)
# Assert: SAAS metadata should NOT be created
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(conv_id)
)
result = await async_session_with_users.execute(saas_query)
saas_metadata = result.scalar_one_or_none()
assert (
saas_metadata is None
), 'SAAS metadata should not be created without user_id'
@pytest.mark.asyncio
async def test_webhook_created_conversation_visible_to_user(
self,
async_session_with_users: AsyncSession,
):
"""Test end-to-end: conversation saved via webhook is visible to the owning user."""
from openhands.app_server.user.specifiy_user_context import ADMIN
# Arrange: Save conversation using ADMIN context (simulating webhook)
admin_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=ADMIN,
)
conv_id = uuid4()
conv_info = AppConversationInfo(
id=conv_id,
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_webhook_e2e',
title='E2E Webhook Conversation',
)
await admin_service.save_app_conversation_info(conv_info)
# Act: Query as the owning user
user1_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
)
user1_page = await user1_service.search_app_conversation_info()
# Assert: User should see the webhook-created conversation
assert len(user1_page.items) == 1
assert user1_page.items[0].id == conv_id
assert user1_page.items[0].title == 'E2E Webhook Conversation'
class TestSandboxIdFilterSaas:
"""Test suite for sandbox_id__eq filter parameter in SAAS service."""
@pytest.mark.asyncio
async def test_search_by_sandbox_id(
self,
async_session_with_users: AsyncSession,
):
"""Test searching conversations by exact sandbox_id match with SAAS user filtering."""
# Create service for user1
user1_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
)
# Create conversations with different sandbox IDs for user1
conv1 = AppConversationInfo(
id=uuid4(),
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_alpha',
title='Conversation Alpha',
created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc),
)
conv2 = AppConversationInfo(
id=uuid4(),
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_beta',
title='Conversation Beta',
created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc),
)
conv3 = AppConversationInfo(
id=uuid4(),
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_alpha',
title='Conversation Gamma',
created_at=datetime(2024, 1, 1, 14, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 14, 30, 0, tzinfo=timezone.utc),
)
# Save all conversations
await user1_service.save_app_conversation_info(conv1)
await user1_service.save_app_conversation_info(conv2)
await user1_service.save_app_conversation_info(conv3)
# Search for sandbox_alpha - should return 2 conversations
page = await user1_service.search_app_conversation_info(
sandbox_id__eq='sandbox_alpha'
)
assert len(page.items) == 2
sandbox_ids = {item.sandbox_id for item in page.items}
assert sandbox_ids == {'sandbox_alpha'}
conversation_ids = {item.id for item in page.items}
assert conv1.id in conversation_ids
assert conv3.id in conversation_ids
# Search for sandbox_beta - should return 1 conversation
page = await user1_service.search_app_conversation_info(
sandbox_id__eq='sandbox_beta'
)
assert len(page.items) == 1
assert page.items[0].id == conv2.id
# Search for non-existent sandbox - should return 0 conversations
page = await user1_service.search_app_conversation_info(
sandbox_id__eq='sandbox_nonexistent'
)
assert len(page.items) == 0
@pytest.mark.asyncio
async def test_count_by_sandbox_id(
self,
async_session_with_users: AsyncSession,
):
"""Test counting conversations by exact sandbox_id match with SAAS user filtering."""
# Create service for user1
user1_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
)
# Create conversations with different sandbox IDs
conv1 = AppConversationInfo(
id=uuid4(),
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_x',
title='Conversation X1',
created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc),
)
conv2 = AppConversationInfo(
id=uuid4(),
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_y',
title='Conversation Y1',
created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc),
)
conv3 = AppConversationInfo(
id=uuid4(),
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_x',
title='Conversation X2',
created_at=datetime(2024, 1, 1, 14, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 14, 30, 0, tzinfo=timezone.utc),
)
# Save all conversations
await user1_service.save_app_conversation_info(conv1)
await user1_service.save_app_conversation_info(conv2)
await user1_service.save_app_conversation_info(conv3)
# Count for sandbox_x - should be 2
count = await user1_service.count_app_conversation_info(
sandbox_id__eq='sandbox_x'
)
assert count == 2
# Count for sandbox_y - should be 1
count = await user1_service.count_app_conversation_info(
sandbox_id__eq='sandbox_y'
)
assert count == 1
# Count for non-existent sandbox - should be 0
count = await user1_service.count_app_conversation_info(
sandbox_id__eq='sandbox_nonexistent'
)
assert count == 0
@pytest.mark.asyncio
async def test_sandbox_id_filter_respects_user_isolation(
self,
async_session_with_users: AsyncSession,
):
"""Test that sandbox_id filter respects user isolation in SAAS environment."""
# Create services for both users
user1_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
)
user2_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=SpecifyUserContext(user_id=str(USER2_ID)),
)
# Create conversation with same sandbox_id for both users
shared_sandbox_id = 'sandbox_shared'
conv_user1 = AppConversationInfo(
id=uuid4(),
created_by_user_id=str(USER1_ID),
sandbox_id=shared_sandbox_id,
title='User1 Conversation',
created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc),
)
conv_user2 = AppConversationInfo(
id=uuid4(),
created_by_user_id=str(USER2_ID),
sandbox_id=shared_sandbox_id,
title='User2 Conversation',
created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc),
)
# Save conversations
await user1_service.save_app_conversation_info(conv_user1)
await user2_service.save_app_conversation_info(conv_user2)
# User1 should only see their own conversation with this sandbox_id
page = await user1_service.search_app_conversation_info(
sandbox_id__eq=shared_sandbox_id
)
assert len(page.items) == 1
assert page.items[0].id == conv_user1.id
assert page.items[0].title == 'User1 Conversation'
# User2 should only see their own conversation with this sandbox_id
page = await user2_service.search_app_conversation_info(
sandbox_id__eq=shared_sandbox_id
)
assert len(page.items) == 1
assert page.items[0].id == conv_user2.id
assert page.items[0].title == 'User2 Conversation'
# Count should also respect user isolation
count = await user1_service.count_app_conversation_info(
sandbox_id__eq=shared_sandbox_id
)
assert count == 1
count = await user2_service.count_app_conversation_info(
sandbox_id__eq=shared_sandbox_id
)
assert count == 1

View File

@@ -1,75 +0,0 @@
"""Unit tests for SlackConversationStore."""
from unittest.mock import patch
import pytest
from sqlalchemy import select
from storage.slack_conversation import SlackConversation
from storage.slack_conversation_store import SlackConversationStore
@pytest.fixture
def slack_conversation_store():
"""Create SlackConversationStore instance."""
return SlackConversationStore()
class TestSlackConversationStore:
"""Test cases for SlackConversationStore."""
@pytest.mark.asyncio
async def test_create_slack_conversation_persists_to_database(
self, slack_conversation_store, async_session_maker
):
"""Test that create_slack_conversation actually stores data in the database.
This test verifies that the await statement is present before session.merge().
Without the await, the data won't be persisted and subsequent lookups will
return None even though we just created the conversation.
"""
channel_id = 'C123456'
parent_id = '1234567890.123456'
conversation_id = 'conv-test-123'
keycloak_user_id = 'user-123'
slack_conversation = SlackConversation(
conversation_id=conversation_id,
channel_id=channel_id,
keycloak_user_id=keycloak_user_id,
parent_id=parent_id,
)
with patch(
'storage.slack_conversation_store.a_session_maker', async_session_maker
):
# Create the slack conversation
await slack_conversation_store.create_slack_conversation(slack_conversation)
# Verify we can retrieve the conversation using the store method
result = await slack_conversation_store.get_slack_conversation(
channel_id=channel_id,
parent_id=parent_id,
)
# This assertion would fail if the await was missing before session.merge()
# because the data wouldn't be persisted to the database
assert result is not None, (
'Slack conversation was not persisted to the database. '
'Ensure await is used before session.merge() in create_slack_conversation.'
)
assert result.conversation_id == conversation_id
assert result.channel_id == channel_id
assert result.parent_id == parent_id
assert result.keycloak_user_id == keycloak_user_id
# Also verify directly in the database
async with async_session_maker() as session:
db_result = await session.execute(
select(SlackConversation).where(
SlackConversation.channel_id == channel_id,
SlackConversation.parent_id == parent_id,
)
)
db_conversation = db_result.scalar_one_or_none()
assert db_conversation is not None
assert db_conversation.conversation_id == conversation_id

View File

@@ -11,6 +11,7 @@ from server.auth.auth_error import AuthError
from server.auth.saas_user_auth import SaasUserAuth
from server.auth.user.user_authorizer import UserAuthorizationResponse, UserAuthorizer
from server.routes.auth import (
_extract_recaptcha_state,
accept_tos,
authenticate,
keycloak_callback,
@@ -54,12 +55,11 @@ def mock_response():
def test_set_response_cookie(mock_response, mock_request):
"""Test setting the auth cookie on a response."""
with (
patch('server.routes.auth.config') as mock_config,
patch('server.utils.url_utils.get_global_config') as get_global_config,
):
with patch('server.routes.auth.config') as mock_config:
mock_config.jwt_secret.get_secret_value.return_value = 'test_secret'
get_global_config.return_value = MagicMock(web_url='https://example.com')
# Configure mock_request.url.hostname
mock_request.url.hostname = 'example.com'
set_response_cookie(
request=mock_request,
@@ -1036,6 +1036,79 @@ async def test_keycloak_callback_no_email_in_user_info(
mock_token_manager.check_duplicate_base_email.assert_not_called()
class TestExtractRecaptchaState:
"""Tests for _extract_recaptcha_state() helper function."""
def test_should_extract_redirect_url_and_token_from_new_json_format(self):
"""Test extraction from new base64-encoded JSON format."""
# Arrange
state_data = {
'redirect_url': 'https://example.com',
'recaptcha_token': 'test-token',
}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
# Act
redirect_url, token = _extract_recaptcha_state(encoded_state)
# Assert
assert redirect_url == 'https://example.com'
assert token == 'test-token'
def test_should_handle_old_format_plain_redirect_url(self):
"""Test handling of old format (plain redirect URL string)."""
# Arrange
state = 'https://example.com'
# Act
redirect_url, token = _extract_recaptcha_state(state)
# Assert
assert redirect_url == 'https://example.com'
assert token is None
def test_should_handle_none_state(self):
"""Test handling of None state."""
# Arrange
state = None
# Act
redirect_url, token = _extract_recaptcha_state(state)
# Assert
assert redirect_url == ''
assert token is None
def test_should_handle_invalid_base64_gracefully(self):
"""Test handling of invalid base64/JSON (fallback to old format)."""
# Arrange
state = 'not-valid-base64!!!'
# Act
redirect_url, token = _extract_recaptcha_state(state)
# Assert
assert redirect_url == state
assert token is None
def test_should_handle_missing_redirect_url_in_json(self):
"""Test handling when redirect_url is missing in JSON."""
# Arrange
state_data = {'recaptcha_token': 'test-token'}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
# Act
redirect_url, token = _extract_recaptcha_state(encoded_state)
# Assert
assert redirect_url == ''
assert token == 'test-token'
class TestKeycloakCallbackRecaptcha:
"""Tests for reCAPTCHA integration in keycloak_callback()."""

View File

@@ -48,7 +48,7 @@ def mock_checkout_request():
'server': ('test.com', 80),
}
)
request._url = URL('http://test.com/')
request._base_url = URL('http://test.com/')
return request
@@ -62,7 +62,7 @@ def mock_subscription_request():
'server': ('test.com', 80),
}
)
request._url = URL('http://test.com/')
request._base_url = URL('http://test.com/')
return request
@@ -264,7 +264,7 @@ async def test_create_checkout_session_success(
async def test_success_callback_session_not_found(async_session_maker):
"""Test success callback when billing session is not found."""
mock_request = Request(scope={'type': 'http'})
mock_request._url = URL('http://test.com/')
mock_request._base_url = URL('http://test.com/')
with (
patch('server.routes.billing.a_session_maker', async_session_maker),
@@ -281,7 +281,7 @@ async def test_success_callback_stripe_incomplete(
):
"""Test success callback when Stripe session is not complete."""
mock_request = Request(scope={'type': 'http'})
mock_request._url = URL('http://test.com/')
mock_request._base_url = URL('http://test.com/')
session_id = 'test_incomplete_session'
async with async_session_maker() as session:
@@ -319,7 +319,7 @@ async def test_success_callback_stripe_incomplete(
async def test_success_callback_success(async_session_maker, test_org, test_user):
"""Test successful payment completion and credit update."""
mock_request = Request(scope={'type': 'http'})
mock_request._url = URL('http://test.com/')
mock_request._base_url = URL('http://test.com/')
session_id = 'test_success_session'
async with async_session_maker() as session:
@@ -391,7 +391,7 @@ async def test_success_callback_lite_llm_error(
):
"""Test handling of LiteLLM API errors during success callback."""
mock_request = Request(scope={'type': 'http'})
mock_request._url = URL('http://test.com/')
mock_request._base_url = URL('http://test.com/')
session_id = 'test_litellm_error_session'
async with async_session_maker() as session:
@@ -445,7 +445,7 @@ async def test_success_callback_lite_llm_update_budget_error_rollback(
the database transaction rolls back.
"""
mock_request = Request(scope={'type': 'http'})
mock_request._url = URL('http://test.com/')
mock_request._base_url = URL('http://test.com/')
session_id = 'test_budget_rollback_session'
async with async_session_maker() as session:
@@ -502,7 +502,7 @@ async def test_success_callback_lite_llm_update_budget_error_rollback(
async def test_cancel_callback_session_not_found(async_session_maker):
"""Test cancel callback when billing session is not found."""
mock_request = Request(scope={'type': 'http'})
mock_request._url = URL('http://test.com/')
mock_request._base_url = URL('http://test.com/')
with patch('server.routes.billing.a_session_maker', async_session_maker):
response = await cancel_callback('nonexistent_session_id', mock_request)
@@ -517,7 +517,7 @@ async def test_cancel_callback_session_not_found(async_session_maker):
async def test_cancel_callback_success(async_session_maker, test_org, test_user):
"""Test successful cancellation of billing session."""
mock_request = Request(scope={'type': 'http'})
mock_request._url = URL('http://test.com/')
mock_request._base_url = URL('http://test.com/')
session_id = 'test_cancel_session'
async with async_session_maker() as session:
@@ -588,7 +588,7 @@ async def test_create_customer_setup_session_success():
'headers': [],
}
)
mock_request._url = URL('http://test.com/')
mock_request._base_url = URL('http://test.com/')
mock_customer_info = {'customer_id': 'mock-customer-id', 'org_id': 'mock-org-id'}
mock_session = MagicMock()
@@ -613,6 +613,6 @@ async def test_create_customer_setup_session_success():
customer='mock-customer-id',
mode='setup',
payment_method_types=['card'],
success_url='https://test.com?setup=success',
cancel_url='https://test.com',
success_url='https://test.com/?setup=success',
cancel_url='https://test.com/',
)

View File

@@ -2,9 +2,7 @@
Unit tests for LiteLlmManager class.
"""
import importlib
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
@@ -23,71 +21,6 @@ from storage.user_settings import UserSettings
from openhands.server.settings import Settings
class TestDefaultInitialBudget:
"""Test cases for DEFAULT_INITIAL_BUDGET configuration."""
@pytest.fixture(autouse=True)
def restore_module_state(self):
"""Ensure module is properly restored after each test."""
# Save original module if it exists
original_module = sys.modules.get('storage.lite_llm_manager')
yield
# Restore module state after each test
if 'storage.lite_llm_manager' in sys.modules:
del sys.modules['storage.lite_llm_manager']
# Clear the env var
os.environ.pop('DEFAULT_INITIAL_BUDGET', None)
# Restore original module or reimport fresh
if original_module is not None:
sys.modules['storage.lite_llm_manager'] = original_module
else:
importlib.import_module('storage.lite_llm_manager')
def test_default_initial_budget_defaults_to_zero(self):
"""Test that DEFAULT_INITIAL_BUDGET defaults to 0.0 when env var not set."""
# Temporarily remove the module so we can reimport with different env vars
if 'storage.lite_llm_manager' in sys.modules:
del sys.modules['storage.lite_llm_manager']
# Clear the env var and reimport
os.environ.pop('DEFAULT_INITIAL_BUDGET', None)
module = importlib.import_module('storage.lite_llm_manager')
assert module.DEFAULT_INITIAL_BUDGET == 0.0
def test_default_initial_budget_uses_env_var(self):
"""Test that DEFAULT_INITIAL_BUDGET uses value from environment variable."""
if 'storage.lite_llm_manager' in sys.modules:
del sys.modules['storage.lite_llm_manager']
os.environ['DEFAULT_INITIAL_BUDGET'] = '100.0'
module = importlib.import_module('storage.lite_llm_manager')
assert module.DEFAULT_INITIAL_BUDGET == 100.0
def test_default_initial_budget_rejects_invalid_value(self):
"""Test that DEFAULT_INITIAL_BUDGET raises ValueError for invalid values."""
if 'storage.lite_llm_manager' in sys.modules:
del sys.modules['storage.lite_llm_manager']
os.environ['DEFAULT_INITIAL_BUDGET'] = 'abc'
with pytest.raises(ValueError) as exc_info:
importlib.import_module('storage.lite_llm_manager')
assert 'Invalid DEFAULT_INITIAL_BUDGET' in str(exc_info.value)
def test_default_initial_budget_rejects_negative_value(self):
"""Test that DEFAULT_INITIAL_BUDGET raises ValueError for negative values."""
if 'storage.lite_llm_manager' in sys.modules:
del sys.modules['storage.lite_llm_manager']
os.environ['DEFAULT_INITIAL_BUDGET'] = '-10.0'
with pytest.raises(ValueError) as exc_info:
importlib.import_module('storage.lite_llm_manager')
assert 'must be non-negative' in str(exc_info.value)
class TestLiteLlmManager:
"""Test cases for LiteLlmManager class."""
@@ -309,10 +242,10 @@ class TestLiteLlmManager:
assert add_user_call[1]['json']['max_budget_in_team'] == 30.0
@pytest.mark.asyncio
async def test_create_entries_new_org_uses_default_initial_budget(
async def test_create_entries_new_org_uses_zero_budget(
self, mock_settings, mock_response
):
"""Test that create_entries uses DEFAULT_INITIAL_BUDGET for new org."""
"""Test that create_entries uses budget=0 for new org (team doesn't exist)."""
mock_404_response = MagicMock()
mock_404_response.status_code = 404
mock_404_response.is_success = False
@@ -340,7 +273,6 @@ class TestLiteLlmManager:
patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'),
patch('storage.lite_llm_manager.TokenManager', mock_token_manager),
patch('httpx.AsyncClient', mock_client_class),
patch('storage.lite_llm_manager.DEFAULT_INITIAL_BUDGET', 0.0),
):
result = await LiteLlmManager.create_entries(
'test-org-id', 'test-user-id', mock_settings, create_user=False
@@ -348,67 +280,16 @@ class TestLiteLlmManager:
assert result is not None
# Verify _create_team was called with DEFAULT_INITIAL_BUDGET (0.0)
# Verify _create_team was called with budget=0
create_team_call = mock_client.post.call_args_list[0]
assert 'team/new' in create_team_call[0][0]
assert create_team_call[1]['json']['max_budget'] == 0.0
# Verify _add_user_to_team was called with DEFAULT_INITIAL_BUDGET (0.0)
# Verify _add_user_to_team was called with budget=0
add_user_call = mock_client.post.call_args_list[1]
assert 'team/member_add' in add_user_call[0][0]
assert add_user_call[1]['json']['max_budget_in_team'] == 0.0
@pytest.mark.asyncio
async def test_create_entries_new_org_uses_custom_default_budget(
self, mock_settings, mock_response
):
"""Test that create_entries uses custom DEFAULT_INITIAL_BUDGET for new org."""
mock_404_response = MagicMock()
mock_404_response.status_code = 404
mock_404_response.is_success = False
mock_token_manager = MagicMock()
mock_token_manager.return_value.get_user_info_from_user_id = AsyncMock(
return_value={'email': 'test@example.com'}
)
mock_client = AsyncMock()
mock_client.get.return_value = mock_404_response
mock_client.get.return_value.raise_for_status.side_effect = (
httpx.HTTPStatusError(
message='Not Found', request=MagicMock(), response=mock_404_response
)
)
mock_client.post.return_value = mock_response
mock_client_class = MagicMock()
mock_client_class.return_value.__aenter__.return_value = mock_client
custom_budget = 50.0
with (
patch.dict(os.environ, {'LOCAL_DEPLOYMENT': ''}),
patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'),
patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'),
patch('storage.lite_llm_manager.TokenManager', mock_token_manager),
patch('httpx.AsyncClient', mock_client_class),
patch('storage.lite_llm_manager.DEFAULT_INITIAL_BUDGET', custom_budget),
):
result = await LiteLlmManager.create_entries(
'test-org-id', 'test-user-id', mock_settings, create_user=False
)
assert result is not None
# Verify _create_team was called with custom DEFAULT_INITIAL_BUDGET
create_team_call = mock_client.post.call_args_list[0]
assert 'team/new' in create_team_call[0][0]
assert create_team_call[1]['json']['max_budget'] == custom_budget
# Verify _add_user_to_team was called with custom DEFAULT_INITIAL_BUDGET
add_user_call = mock_client.post.call_args_list[1]
assert 'team/member_add' in add_user_call[0][0]
assert add_user_call[1]['json']['max_budget_in_team'] == custom_budget
@pytest.mark.asyncio
async def test_create_entries_propagates_non_404_errors(self, mock_settings):
"""Test that create_entries propagates non-404 errors from _get_team."""

View File

@@ -98,11 +98,6 @@ class TestAcceptInvitationEmailValidation:
mock_keycloak_user_info = {'email': 'alice@example.com'} # Email from Keycloak
mock_org = MagicMock()
mock_org.default_llm_model = 'test-model'
mock_org.default_llm_base_url = None
mock_org.default_max_iterations = None
with (
patch(
'server.services.org_invitation_service.OrgInvitationStore.get_invitation_by_token',
@@ -126,10 +121,6 @@ class TestAcceptInvitationEmailValidation:
'server.services.org_invitation_service.OrgService.create_litellm_integration',
new_callable=AsyncMock,
) as mock_create_litellm,
patch(
'server.services.org_invitation_service.OrgStore.get_org_by_id',
new_callable=AsyncMock,
) as mock_get_org,
patch(
'server.services.org_invitation_service.OrgMemberStore.add_user_to_org',
new_callable=AsyncMock,
@@ -154,7 +145,6 @@ class TestAcceptInvitationEmailValidation:
mock_settings = MagicMock()
mock_settings.llm_api_key = SecretStr('test-key')
mock_create_litellm.return_value = mock_settings
mock_get_org.return_value = mock_org
mock_update_status.return_value = mock_invitation
# Act - should not raise error because Keycloak email matches
@@ -224,11 +214,6 @@ class TestAcceptInvitationEmailValidation:
mock_invitation.email = 'alice@example.com' # Lowercase in invitation
mock_org = MagicMock()
mock_org.default_llm_model = 'test-model'
mock_org.default_llm_base_url = None
mock_org.default_max_iterations = None
with (
patch(
'server.services.org_invitation_service.OrgInvitationStore.get_invitation_by_token',
@@ -249,10 +234,6 @@ class TestAcceptInvitationEmailValidation:
'server.services.org_invitation_service.OrgService.create_litellm_integration',
new_callable=AsyncMock,
) as mock_create_litellm,
patch(
'server.services.org_invitation_service.OrgStore.get_org_by_id',
new_callable=AsyncMock,
) as mock_get_org,
patch(
'server.services.org_invitation_service.OrgMemberStore.add_user_to_org',
new_callable=AsyncMock,
@@ -269,7 +250,6 @@ class TestAcceptInvitationEmailValidation:
mock_settings = MagicMock()
mock_settings.llm_api_key = SecretStr('test-key')
mock_create_litellm.return_value = mock_settings
mock_get_org.return_value = mock_org
mock_update_status.return_value = mock_invitation
# Act - should not raise error because emails match case-insensitively
@@ -278,75 +258,6 @@ class TestAcceptInvitationEmailValidation:
# Assert - invitation was accepted (update_invitation_status was called)
mock_update_status.assert_called_once()
@pytest.mark.asyncio
async def test_accept_invitation_inherits_org_llm_settings(self, mock_invitation):
"""Test that new members inherit the organization's LLM settings when accepting invitation."""
# Arrange
user_id = UUID('87654321-4321-8765-4321-876543218765')
token = 'inv-test-token-12345'
mock_user = MagicMock()
mock_user.id = user_id
mock_user.email = 'alice@example.com'
mock_org = MagicMock()
mock_org.default_llm_model = 'claude-sonnet-4'
mock_org.default_llm_base_url = 'https://api.anthropic.com'
mock_org.default_max_iterations = 100
with (
patch(
'server.services.org_invitation_service.OrgInvitationStore.get_invitation_by_token',
new_callable=AsyncMock,
) as mock_get_invitation,
patch(
'server.services.org_invitation_service.OrgInvitationStore.is_token_expired'
) as mock_is_expired,
patch(
'server.services.org_invitation_service.UserStore.get_user_by_id',
new_callable=AsyncMock,
) as mock_get_user,
patch(
'server.services.org_invitation_service.OrgMemberStore.get_org_member',
new_callable=AsyncMock,
) as mock_get_member,
patch(
'server.services.org_invitation_service.OrgService.create_litellm_integration',
new_callable=AsyncMock,
) as mock_create_litellm,
patch(
'server.services.org_invitation_service.OrgStore.get_org_by_id',
new_callable=AsyncMock,
) as mock_get_org,
patch(
'server.services.org_invitation_service.OrgMemberStore.add_user_to_org',
new_callable=AsyncMock,
) as mock_add_user,
patch(
'server.services.org_invitation_service.OrgInvitationStore.update_invitation_status',
new_callable=AsyncMock,
) as mock_update_status,
):
mock_get_invitation.return_value = mock_invitation
mock_is_expired.return_value = False
mock_get_user.return_value = mock_user
mock_get_member.return_value = None
mock_settings = MagicMock()
mock_settings.llm_api_key = SecretStr('test-key')
mock_create_litellm.return_value = mock_settings
mock_get_org.return_value = mock_org
mock_update_status.return_value = mock_invitation
# Act
await OrgInvitationService.accept_invitation(token, user_id)
# Assert - verify add_user_to_org was called with org's LLM settings
mock_add_user.assert_called_once()
call_kwargs = mock_add_user.call_args.kwargs
assert call_kwargs['llm_model'] == 'claude-sonnet-4'
assert call_kwargs['llm_base_url'] == 'https://api.anthropic.com'
assert call_kwargs['max_iterations'] == 100
class TestCreateInvitationsBatch:
"""Test cases for batch invitation creation."""

View File

@@ -246,43 +246,6 @@ async def test_add_user_to_org(async_session_maker):
assert org_member.status == 'active'
@pytest.mark.asyncio
async def test_add_user_to_org_with_llm_settings(async_session_maker):
"""Test that add_user_to_org correctly sets inherited LLM settings from organization."""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org-llm')
session.add(org)
await session.flush()
user = User(id=uuid.uuid4(), current_org_id=org.id)
role = Role(name='member', rank=2)
session.add_all([user, role])
await session.commit()
org_id = org.id
user_id = user.id
role_id = role.id
# Act
with patch('storage.org_member_store.a_session_maker', async_session_maker):
org_member = await OrgMemberStore.add_user_to_org(
org_id=org_id,
user_id=user_id,
role_id=role_id,
llm_api_key='test-api-key',
status='active',
llm_model='claude-sonnet-4',
llm_base_url='https://api.example.com',
max_iterations=50,
)
# Assert
assert org_member is not None
assert org_member.llm_model == 'claude-sonnet-4'
assert org_member.llm_base_url == 'https://api.example.com'
assert org_member.max_iterations == 50
@pytest.mark.asyncio
async def test_update_user_role_in_org(async_session_maker):
# Test updating user role in org

View File

@@ -144,86 +144,6 @@ async def test_create_org(async_session_maker, mock_litellm_api):
assert org.id is not None
@pytest.mark.asyncio
async def test_create_org_v1_enabled_defaults_to_true_when_default_is_true(
async_session_maker, mock_litellm_api
):
"""
GIVEN: DEFAULT_V1_ENABLED is True and org.v1_enabled is not specified (None)
WHEN: create_org is called
THEN: org.v1_enabled should be set to True
"""
with (
patch('storage.org_store.a_session_maker', async_session_maker),
patch('storage.org_store.DEFAULT_V1_ENABLED', True),
):
org = await OrgStore.create_org(kwargs={'name': 'test-org-v1-default-true'})
assert org is not None
assert org.v1_enabled is True
@pytest.mark.asyncio
async def test_create_org_v1_enabled_defaults_to_false_when_default_is_false(
async_session_maker, mock_litellm_api
):
"""
GIVEN: DEFAULT_V1_ENABLED is False and org.v1_enabled is not specified (None)
WHEN: create_org is called
THEN: org.v1_enabled should be set to False
"""
with (
patch('storage.org_store.a_session_maker', async_session_maker),
patch('storage.org_store.DEFAULT_V1_ENABLED', False),
):
org = await OrgStore.create_org(kwargs={'name': 'test-org-v1-default-false'})
assert org is not None
assert org.v1_enabled is False
@pytest.mark.asyncio
async def test_create_org_v1_enabled_explicit_false_overrides_default_true(
async_session_maker, mock_litellm_api
):
"""
GIVEN: DEFAULT_V1_ENABLED is True but org.v1_enabled is explicitly set to False
WHEN: create_org is called
THEN: org.v1_enabled should stay False (explicit value wins over default)
"""
with (
patch('storage.org_store.a_session_maker', async_session_maker),
patch('storage.org_store.DEFAULT_V1_ENABLED', True),
):
org = await OrgStore.create_org(
kwargs={'name': 'test-org-v1-explicit-false', 'v1_enabled': False}
)
assert org is not None
assert org.v1_enabled is False
@pytest.mark.asyncio
async def test_create_org_v1_enabled_explicit_true_overrides_default_false(
async_session_maker, mock_litellm_api
):
"""
GIVEN: DEFAULT_V1_ENABLED is False but org.v1_enabled is explicitly set to True
WHEN: create_org is called
THEN: org.v1_enabled should stay True (explicit value wins over default)
"""
with (
patch('storage.org_store.a_session_maker', async_session_maker),
patch('storage.org_store.DEFAULT_V1_ENABLED', False),
):
org = await OrgStore.create_org(
kwargs={'name': 'test-org-v1-explicit-true', 'v1_enabled': True}
)
assert org is not None
assert org.v1_enabled is True
@pytest.mark.asyncio
async def test_get_org_by_name(async_session_maker, mock_litellm_api):
# Test getting org by name

View File

@@ -396,44 +396,3 @@ async def test_store_propagates_llm_settings_to_all_org_members(
assert (
decrypted_key == 'new-shared-api-key'
), f'Expected llm_api_key to decrypt to new-shared-api-key for member {member.user_id}'
@pytest.mark.asyncio
async def test_store_updates_org_default_llm_settings(
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
):
"""When admin saves LLM settings, org's default_llm_model/base_url/max_iterations should be updated.
This test verifies that the Org table's default settings are updated so that
new members joining later will inherit the correct LLM configuration.
"""
from sqlalchemy import select
from storage.org import Org
# Arrange
fixture = org_with_multiple_members_fixture
org_id = fixture['org_id']
admin_user_id = str(fixture['admin_user_id'])
store = SaasSettingsStore(admin_user_id, mock_config)
new_settings = DataSettings(
llm_model='anthropic/claude-sonnet-4',
llm_base_url='https://api.anthropic.com/v1',
max_iterations=75,
llm_api_key=SecretStr('test-api-key'),
)
# Act
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
await store.store(new_settings)
# Assert - verify org's default fields were updated
with session_maker() as session:
result = session.execute(select(Org).where(Org.id == org_id))
org = result.scalars().first()
assert org is not None
assert org.default_llm_model == 'anthropic/claude-sonnet-4'
assert org.default_llm_base_url == 'https://api.anthropic.com/v1'
assert org.default_max_iterations == 75

View File

@@ -1,555 +0,0 @@
"""Tests for AwsSharedEventService."""
import os
from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from server.sharing.aws_shared_event_service import (
AwsSharedEventService,
AwsSharedEventServiceInjector,
)
from server.sharing.shared_conversation_info_service import (
SharedConversationInfoService,
)
from server.sharing.shared_conversation_models import SharedConversation
from openhands.agent_server.models import EventPage, EventSortOrder
from openhands.app_server.event.event_service import EventService
from openhands.sdk.llm import MetricsSnapshot
from openhands.sdk.llm.utils.metrics import TokenUsage
@pytest.fixture
def mock_shared_conversation_info_service():
"""Create a mock SharedConversationInfoService."""
return AsyncMock(spec=SharedConversationInfoService)
@pytest.fixture
def mock_s3_client():
"""Create a mock S3 client."""
return MagicMock()
@pytest.fixture
def mock_event_service():
"""Create a mock EventService for returned by get_event_service."""
return AsyncMock(spec=EventService)
@pytest.fixture
def aws_shared_event_service(mock_shared_conversation_info_service, mock_s3_client):
"""Create an AwsSharedEventService for testing."""
return AwsSharedEventService(
shared_conversation_info_service=mock_shared_conversation_info_service,
s3_client=mock_s3_client,
bucket_name='test-bucket',
)
@pytest.fixture
def sample_public_conversation():
"""Create a sample public conversation."""
return SharedConversation(
id=uuid4(),
created_by_user_id='test_user',
sandbox_id='test_sandbox',
title='Test Public Conversation',
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
metrics=MetricsSnapshot(
accumulated_cost=0.0,
max_budget_per_task=10.0,
accumulated_token_usage=TokenUsage(),
),
)
@pytest.fixture
def sample_event():
"""Create a sample event."""
# For testing purposes, we'll just use a mock that the EventPage can accept
# The actual event creation is complex and not the focus of these tests
return None
class TestAwsSharedEventService:
"""Test cases for AwsSharedEventService."""
async def test_get_shared_event_returns_event_for_public_conversation(
self,
aws_shared_event_service,
mock_shared_conversation_info_service,
mock_event_service,
sample_public_conversation,
sample_event,
):
"""Test that get_shared_event returns an event for a public conversation."""
conversation_id = sample_public_conversation.id
event_id = uuid4()
# Mock the public conversation service to return a public conversation
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = sample_public_conversation
# Mock get_event_service to return our mock event service
aws_shared_event_service.get_event_service = AsyncMock(
return_value=mock_event_service
)
# Mock the event service to return an event
mock_event_service.get_event.return_value = sample_event
# Call the method
result = await aws_shared_event_service.get_shared_event(
conversation_id, event_id
)
# Verify the result
assert result == sample_event
aws_shared_event_service.get_event_service.assert_called_once_with(
conversation_id
)
mock_event_service.get_event.assert_called_once_with(conversation_id, event_id)
async def test_get_shared_event_returns_none_for_private_conversation(
self,
aws_shared_event_service,
mock_shared_conversation_info_service,
mock_event_service,
):
"""Test that get_shared_event returns None for a private conversation."""
conversation_id = uuid4()
event_id = uuid4()
# Mock get_event_service to return None (private conversation)
aws_shared_event_service.get_event_service = AsyncMock(return_value=None)
# Call the method
result = await aws_shared_event_service.get_shared_event(
conversation_id, event_id
)
# Verify the result
assert result is None
aws_shared_event_service.get_event_service.assert_called_once_with(
conversation_id
)
# Event service should not be called since get_event_service returns None
mock_event_service.get_event.assert_not_called()
async def test_search_shared_events_returns_events_for_public_conversation(
self,
aws_shared_event_service,
mock_shared_conversation_info_service,
mock_event_service,
sample_public_conversation,
sample_event,
):
"""Test that search_shared_events returns events for a public conversation."""
conversation_id = sample_public_conversation.id
# Mock get_event_service to return our mock event service
aws_shared_event_service.get_event_service = AsyncMock(
return_value=mock_event_service
)
# Mock the event service to return events
mock_event_page = EventPage(items=[], next_page_id=None)
mock_event_service.search_events.return_value = mock_event_page
# Call the method
result = await aws_shared_event_service.search_shared_events(
conversation_id=conversation_id,
kind__eq='ActionEvent',
limit=10,
)
# Verify the result
assert result == mock_event_page
assert len(result.items) == 0 # Empty list as we mocked
aws_shared_event_service.get_event_service.assert_called_once_with(
conversation_id
)
mock_event_service.search_events.assert_called_once_with(
conversation_id=conversation_id,
kind__eq='ActionEvent',
timestamp__gte=None,
timestamp__lt=None,
sort_order=EventSortOrder.TIMESTAMP,
page_id=None,
limit=10,
)
async def test_search_shared_events_returns_empty_for_private_conversation(
self,
aws_shared_event_service,
mock_shared_conversation_info_service,
mock_event_service,
):
"""Test that search_shared_events returns empty page for a private conversation."""
conversation_id = uuid4()
# Mock get_event_service to return None (private conversation)
aws_shared_event_service.get_event_service = AsyncMock(return_value=None)
# Call the method
result = await aws_shared_event_service.search_shared_events(
conversation_id=conversation_id,
limit=10,
)
# Verify the result
assert isinstance(result, EventPage)
assert len(result.items) == 0
assert result.next_page_id is None
aws_shared_event_service.get_event_service.assert_called_once_with(
conversation_id
)
# Event service should not be called
mock_event_service.search_events.assert_not_called()
async def test_count_shared_events_returns_count_for_public_conversation(
self,
aws_shared_event_service,
mock_shared_conversation_info_service,
mock_event_service,
sample_public_conversation,
):
"""Test that count_shared_events returns count for a public conversation."""
conversation_id = sample_public_conversation.id
# Mock get_event_service to return our mock event service
aws_shared_event_service.get_event_service = AsyncMock(
return_value=mock_event_service
)
# Mock the event service to return a count
mock_event_service.count_events.return_value = 5
# Call the method
result = await aws_shared_event_service.count_shared_events(
conversation_id=conversation_id,
kind__eq='ActionEvent',
)
# Verify the result
assert result == 5
aws_shared_event_service.get_event_service.assert_called_once_with(
conversation_id
)
mock_event_service.count_events.assert_called_once_with(
conversation_id=conversation_id,
kind__eq='ActionEvent',
timestamp__gte=None,
timestamp__lt=None,
)
async def test_count_shared_events_returns_zero_for_private_conversation(
self,
aws_shared_event_service,
mock_shared_conversation_info_service,
mock_event_service,
):
"""Test that count_shared_events returns 0 for a private conversation."""
conversation_id = uuid4()
# Mock get_event_service to return None (private conversation)
aws_shared_event_service.get_event_service = AsyncMock(return_value=None)
# Call the method
result = await aws_shared_event_service.count_shared_events(
conversation_id=conversation_id,
)
# Verify the result
assert result == 0
aws_shared_event_service.get_event_service.assert_called_once_with(
conversation_id
)
# Event service should not be called
mock_event_service.count_events.assert_not_called()
async def test_batch_get_shared_events_returns_events_for_public_conversation(
self,
aws_shared_event_service,
mock_shared_conversation_info_service,
mock_event_service,
sample_public_conversation,
sample_event,
):
"""Test that batch_get_shared_events returns events for a public conversation."""
conversation_id = sample_public_conversation.id
event_ids = [uuid4() for _ in range(3)]
# Mock get_event_service to return our mock event service
aws_shared_event_service.get_event_service = AsyncMock(
return_value=mock_event_service
)
# Mock the event service to return events
mock_event_service.get_event.return_value = sample_event
# Call the method
results = await aws_shared_event_service.batch_get_shared_events(
conversation_id, event_ids
)
# Verify the results
assert len(results) == 3
assert all(result == sample_event for result in results)
class TestAwsSharedEventServiceGetEventService:
"""Test cases for AwsSharedEventService.get_event_service method."""
async def test_get_event_service_returns_event_service_for_shared_conversation(
self,
aws_shared_event_service,
mock_shared_conversation_info_service,
sample_public_conversation,
):
"""Test that get_event_service returns an EventService for a shared conversation."""
conversation_id = sample_public_conversation.id
# Mock the shared conversation info service to return a shared conversation
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = sample_public_conversation
# Call the method
result = await aws_shared_event_service.get_event_service(conversation_id)
# Verify the result
assert result is not None
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
conversation_id
)
async def test_get_event_service_returns_none_for_non_shared_conversation(
self,
aws_shared_event_service,
mock_shared_conversation_info_service,
):
"""Test that get_event_service returns None for a non-shared conversation."""
conversation_id = uuid4()
# Mock the shared conversation info service to return None
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = None
# Call the method
result = await aws_shared_event_service.get_event_service(conversation_id)
# Verify the result
assert result is None
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
conversation_id
)
class TestAwsSharedEventServiceInjector:
"""Test cases for AwsSharedEventServiceInjector."""
def test_bucket_name_from_environment_variable(self):
"""Test that bucket_name is read from FILE_STORE_PATH environment variable."""
test_bucket_name = 'test-bucket-name'
with patch.dict(os.environ, {'FILE_STORE_PATH': test_bucket_name}):
# Create a new injector instance to pick up the environment variable
# Note: The class attribute is evaluated at class definition time,
# so we need to test that the attribute exists and can be overridden
injector = AwsSharedEventServiceInjector()
injector.bucket_name = os.environ.get('FILE_STORE_PATH')
assert injector.bucket_name == test_bucket_name
def test_bucket_name_default_value_when_env_not_set(self):
"""Test that bucket_name is None when FILE_STORE_PATH is not set."""
with patch.dict(os.environ, {}, clear=True):
# Remove FILE_STORE_PATH if it exists
os.environ.pop('FILE_STORE_PATH', None)
injector = AwsSharedEventServiceInjector()
# The bucket_name will be whatever was set at class definition time
# or None if FILE_STORE_PATH was not set when the class was defined
assert hasattr(injector, 'bucket_name')
async def test_injector_yields_aws_shared_event_service(self):
"""Test that the injector yields an AwsSharedEventService instance."""
mock_state = MagicMock()
mock_request = MagicMock()
mock_db_session = AsyncMock()
# Create the injector
injector = AwsSharedEventServiceInjector()
injector.bucket_name = 'test-bucket'
# Mock the get_db_session context manager
mock_db_context = AsyncMock()
mock_db_context.__aenter__.return_value = mock_db_session
mock_db_context.__aexit__.return_value = None
# Mock boto3.client
mock_s3_client = MagicMock()
with (
patch(
'server.sharing.aws_shared_event_service.boto3.client',
return_value=mock_s3_client,
),
patch(
'openhands.app_server.config.get_db_session',
return_value=mock_db_context,
),
):
# Call the inject method
async for service in injector.inject(mock_state, mock_request):
# Verify the service is an instance of AwsSharedEventService
assert isinstance(service, AwsSharedEventService)
assert service.s3_client == mock_s3_client
assert service.bucket_name == 'test-bucket'
async def test_injector_uses_bucket_name_from_instance(self):
"""Test that the injector uses the bucket_name from the instance."""
mock_state = MagicMock()
mock_request = MagicMock()
mock_db_session = AsyncMock()
# Create the injector with a specific bucket name
injector = AwsSharedEventServiceInjector()
injector.bucket_name = 'my-custom-bucket'
# Mock the get_db_session context manager
mock_db_context = AsyncMock()
mock_db_context.__aenter__.return_value = mock_db_session
mock_db_context.__aexit__.return_value = None
# Mock boto3.client
mock_s3_client = MagicMock()
with (
patch(
'server.sharing.aws_shared_event_service.boto3.client',
return_value=mock_s3_client,
),
patch(
'openhands.app_server.config.get_db_session',
return_value=mock_db_context,
),
):
# Call the inject method
async for service in injector.inject(mock_state, mock_request):
assert service.bucket_name == 'my-custom-bucket'
async def test_injector_creates_sql_shared_conversation_info_service(self):
"""Test that the injector creates SQLSharedConversationInfoService with db_session."""
mock_state = MagicMock()
mock_request = MagicMock()
mock_db_session = AsyncMock()
# Create the injector
injector = AwsSharedEventServiceInjector()
injector.bucket_name = 'test-bucket'
# Mock the get_db_session context manager
mock_db_context = AsyncMock()
mock_db_context.__aenter__.return_value = mock_db_session
mock_db_context.__aexit__.return_value = None
# Mock boto3.client
mock_s3_client = MagicMock()
with (
patch(
'server.sharing.aws_shared_event_service.boto3.client',
return_value=mock_s3_client,
),
patch(
'openhands.app_server.config.get_db_session',
return_value=mock_db_context,
),
patch(
'server.sharing.aws_shared_event_service.SQLSharedConversationInfoService'
) as mock_sql_service_class,
):
mock_sql_service = MagicMock()
mock_sql_service_class.return_value = mock_sql_service
# Call the inject method
async for service in injector.inject(mock_state, mock_request):
# Verify the service has the correct shared_conversation_info_service
assert service.shared_conversation_info_service == mock_sql_service
# Verify SQLSharedConversationInfoService was created with db_session
mock_sql_service_class.assert_called_once_with(db_session=mock_db_session)
async def test_injector_works_without_request(self):
"""Test that the injector works when request is None."""
mock_state = MagicMock()
mock_db_session = AsyncMock()
# Create the injector
injector = AwsSharedEventServiceInjector()
injector.bucket_name = 'test-bucket'
# Mock the get_db_session context manager
mock_db_context = AsyncMock()
mock_db_context.__aenter__.return_value = mock_db_session
mock_db_context.__aexit__.return_value = None
# Mock boto3.client
mock_s3_client = MagicMock()
with (
patch(
'server.sharing.aws_shared_event_service.boto3.client',
return_value=mock_s3_client,
),
patch(
'openhands.app_server.config.get_db_session',
return_value=mock_db_context,
),
):
# Call the inject method with request=None
async for service in injector.inject(mock_state, request=None):
assert isinstance(service, AwsSharedEventService)
async def test_injector_uses_role_based_authentication(self):
"""Test that the injector uses role-based authentication (no explicit credentials)."""
mock_state = MagicMock()
mock_request = MagicMock()
mock_db_session = AsyncMock()
# Create the injector
injector = AwsSharedEventServiceInjector()
injector.bucket_name = 'test-bucket'
# Mock the get_db_session context manager
mock_db_context = AsyncMock()
mock_db_context.__aenter__.return_value = mock_db_session
mock_db_context.__aexit__.return_value = None
# Mock boto3.client
mock_s3_client = MagicMock()
with (
patch(
'server.sharing.aws_shared_event_service.boto3.client',
return_value=mock_s3_client,
) as mock_boto3_client,
patch(
'openhands.app_server.config.get_db_session',
return_value=mock_db_context,
),
patch.dict(os.environ, {'AWS_S3_ENDPOINT': 'https://s3.example.com'}),
):
# Call the inject method
async for service in injector.inject(mock_state, mock_request):
pass
# Verify boto3.client was called with 's3' and endpoint_url
# but without explicit credentials (role-based auth)
mock_boto3_client.assert_called_once_with(
's3',
endpoint_url='https://s3.example.com',
)

View File

@@ -1,171 +0,0 @@
"""Tests for shared_event_router provider selection.
This module tests the get_shared_event_service_injector function which
determines which SharedEventServiceInjector to use based on environment variables.
"""
import os
from unittest.mock import patch
from server.sharing.aws_shared_event_service import AwsSharedEventServiceInjector
from server.sharing.google_cloud_shared_event_service import (
GoogleCloudSharedEventServiceInjector,
)
from server.sharing.shared_event_router import get_shared_event_service_injector
class TestGetSharedEventServiceInjector:
"""Test cases for get_shared_event_service_injector function."""
def test_defaults_to_google_cloud_when_no_env_set(self):
"""Test that GoogleCloudSharedEventServiceInjector is used when no env is set."""
with patch.dict(
os.environ,
{},
clear=True,
):
os.environ.pop('SHARED_EVENT_STORAGE_PROVIDER', None)
os.environ.pop('FILE_STORE', None)
injector = get_shared_event_service_injector()
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
def test_uses_google_cloud_when_file_store_google_cloud(self):
"""Test that GoogleCloudSharedEventServiceInjector is used when FILE_STORE=google_cloud."""
with patch.dict(
os.environ,
{
'FILE_STORE': 'google_cloud',
},
clear=True,
):
os.environ.pop('SHARED_EVENT_STORAGE_PROVIDER', None)
injector = get_shared_event_service_injector()
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
def test_uses_aws_when_provider_aws(self):
"""Test that AwsSharedEventServiceInjector is used when SHARED_EVENT_STORAGE_PROVIDER=aws."""
with patch.dict(
os.environ,
{
'SHARED_EVENT_STORAGE_PROVIDER': 'aws',
},
clear=True,
):
injector = get_shared_event_service_injector()
assert isinstance(injector, AwsSharedEventServiceInjector)
def test_uses_gcp_when_provider_gcp(self):
"""Test that GoogleCloudSharedEventServiceInjector is used when SHARED_EVENT_STORAGE_PROVIDER=gcp."""
with patch.dict(
os.environ,
{
'SHARED_EVENT_STORAGE_PROVIDER': 'gcp',
},
clear=True,
):
injector = get_shared_event_service_injector()
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
def test_uses_gcp_when_provider_google_cloud(self):
"""Test that GoogleCloudSharedEventServiceInjector is used when SHARED_EVENT_STORAGE_PROVIDER=google_cloud."""
with patch.dict(
os.environ,
{
'SHARED_EVENT_STORAGE_PROVIDER': 'google_cloud',
},
clear=True,
):
injector = get_shared_event_service_injector()
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
def test_provider_takes_precedence_over_file_store(self):
"""Test that SHARED_EVENT_STORAGE_PROVIDER takes precedence over FILE_STORE."""
with patch.dict(
os.environ,
{
'SHARED_EVENT_STORAGE_PROVIDER': 'aws',
'FILE_STORE': 'google_cloud',
},
clear=True,
):
injector = get_shared_event_service_injector()
# Should use AWS because SHARED_EVENT_STORAGE_PROVIDER takes precedence
assert isinstance(injector, AwsSharedEventServiceInjector)
def test_provider_gcp_takes_precedence_over_file_store_s3(self):
"""Test that SHARED_EVENT_STORAGE_PROVIDER=gcp takes precedence over FILE_STORE=s3."""
with patch.dict(
os.environ,
{
'SHARED_EVENT_STORAGE_PROVIDER': 'gcp',
'FILE_STORE': 's3',
},
clear=True,
):
injector = get_shared_event_service_injector()
# Should use GCP because SHARED_EVENT_STORAGE_PROVIDER takes precedence
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
def test_provider_is_case_insensitive_aws(self):
"""Test that SHARED_EVENT_STORAGE_PROVIDER is case insensitive for AWS."""
with patch.dict(
os.environ,
{
'SHARED_EVENT_STORAGE_PROVIDER': 'AWS',
},
clear=True,
):
injector = get_shared_event_service_injector()
assert isinstance(injector, AwsSharedEventServiceInjector)
def test_provider_is_case_insensitive_gcp(self):
"""Test that SHARED_EVENT_STORAGE_PROVIDER is case insensitive for GCP."""
with patch.dict(
os.environ,
{
'SHARED_EVENT_STORAGE_PROVIDER': 'GCP',
},
clear=True,
):
injector = get_shared_event_service_injector()
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
def test_unknown_provider_defaults_to_google_cloud(self):
"""Test that unknown provider defaults to GoogleCloudSharedEventServiceInjector."""
with patch.dict(
os.environ,
{
'SHARED_EVENT_STORAGE_PROVIDER': 'unknown_provider',
},
clear=True,
):
injector = get_shared_event_service_injector()
# Should default to GCP for unknown providers
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
def test_empty_provider_falls_back_to_file_store(self):
"""Test that empty SHARED_EVENT_STORAGE_PROVIDER falls back to FILE_STORE."""
with patch.dict(
os.environ,
{
'SHARED_EVENT_STORAGE_PROVIDER': '',
'FILE_STORE': 'google_cloud',
},
clear=True,
):
injector = get_shared_event_service_injector()
# Should default to GCP for unknown providers
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)

View File

@@ -101,72 +101,6 @@ async def test_create_default_settings_with_litellm(mock_litellm_api):
assert settings.llm_base_url == 'http://test.url'
@pytest.mark.asyncio
async def test_create_default_settings_v1_enabled_true_when_default_is_true(
mock_litellm_api,
):
"""
GIVEN: DEFAULT_V1_ENABLED is True
WHEN: create_default_settings is called
THEN: The default_settings.v1_enabled should be set to True
"""
org_id = str(uuid.uuid4())
user_id = str(uuid.uuid4())
# Track the settings passed to LiteLlmManager.create_entries
captured_settings = None
async def capture_create_entries(_org_id, _user_id, settings, _create_user):
nonlocal captured_settings
captured_settings = settings
return settings
with (
patch('storage.user_store.DEFAULT_V1_ENABLED', True),
patch(
'storage.lite_llm_manager.LiteLlmManager.create_entries',
side_effect=capture_create_entries,
),
):
await UserStore.create_default_settings(org_id, user_id)
assert captured_settings is not None
assert captured_settings.v1_enabled is True
@pytest.mark.asyncio
async def test_create_default_settings_v1_enabled_false_when_default_is_false(
mock_litellm_api,
):
"""
GIVEN: DEFAULT_V1_ENABLED is False
WHEN: create_default_settings is called
THEN: The default_settings.v1_enabled should be set to False
"""
org_id = str(uuid.uuid4())
user_id = str(uuid.uuid4())
# Track the settings passed to LiteLlmManager.create_entries
captured_settings = None
async def capture_create_entries(_org_id, _user_id, settings, _create_user):
nonlocal captured_settings
captured_settings = settings
return settings
with (
patch('storage.user_store.DEFAULT_V1_ENABLED', False),
patch(
'storage.lite_llm_manager.LiteLlmManager.create_entries',
side_effect=capture_create_entries,
),
):
await UserStore.create_default_settings(org_id, user_id)
assert captured_settings is not None
assert captured_settings.v1_enabled is False
# --- Tests for get_user_by_id ---
@@ -1309,19 +1243,3 @@ async def test_migrate_user_sql_multiple_conversations(async_session_maker):
assert (
row.org_id == user_uuid_str
), f'org_id should match: {row.org_id} vs {user_uuid_str}'
# Note: The v1_enabled logic in migrate_user follows the same pattern as OrgStore.create_org:
# if org.v1_enabled is None:
# org.v1_enabled = DEFAULT_V1_ENABLED
#
# This behavior is tested in test_org_store.py via:
# - test_create_org_v1_enabled_defaults_to_true_when_default_is_true
# - test_create_org_v1_enabled_defaults_to_false_when_default_is_false
# - test_create_org_v1_enabled_explicit_false_overrides_default_true
# - test_create_org_v1_enabled_explicit_true_overrides_default_false
#
# Testing migrate_user directly is impractical due to its complex raw SQL migration
# statements that have SQLite/UUID compatibility issues in the test environment.
# The SQL migration tests above (test_migrate_user_sql_type_handling, etc.) verify
# the SQL operations work correctly with proper type handling.

View File

@@ -1 +0,0 @@
# Tests for enterprise server utils

View File

@@ -1,425 +0,0 @@
"""Tests for URL utility functions that prevent URL hijacking attacks."""
from unittest.mock import MagicMock, patch
import pytest
class TestGetWebUrl:
"""Tests for get_web_url function."""
@pytest.fixture
def mock_request(self):
"""Create a mock FastAPI request object."""
request = MagicMock()
request.url = MagicMock()
return request
def test_configured_web_url_is_used(self, mock_request):
"""When web_url is configured, it should be used instead of request URL."""
from server.utils.url_utils import get_web_url
mock_request.url.hostname = 'evil-attacker.com'
mock_request.url.netloc = 'evil-attacker.com:443'
mock_config = MagicMock()
mock_config.web_url = 'https://app.all-hands.dev'
with patch(
'server.utils.url_utils.get_global_config', return_value=mock_config
):
result = get_web_url(mock_request)
assert result == 'https://app.all-hands.dev'
# Should not use any info from the potentially poisoned request
assert 'evil-attacker.com' not in result
def test_configured_web_url_trailing_slash_stripped(self, mock_request):
"""Configured web_url should have trailing slashes stripped."""
from server.utils.url_utils import get_web_url
mock_config = MagicMock()
mock_config.web_url = 'https://app.all-hands.dev/'
with patch(
'server.utils.url_utils.get_global_config', return_value=mock_config
):
result = get_web_url(mock_request)
assert result == 'https://app.all-hands.dev'
assert not result.endswith('/')
def test_unconfigured_web_url_localhost_uses_http(self, mock_request):
"""When web_url is not configured and hostname is localhost, use http."""
from server.utils.url_utils import get_web_url
mock_request.url.hostname = 'localhost'
mock_request.url.netloc = 'localhost:3000'
mock_config = MagicMock()
mock_config.web_url = None
with patch(
'server.utils.url_utils.get_global_config', return_value=mock_config
):
result = get_web_url(mock_request)
assert result == 'http://localhost:3000'
def test_unconfigured_web_url_non_localhost_uses_https(self, mock_request):
"""When web_url is not configured and hostname is not localhost, use https."""
from server.utils.url_utils import get_web_url
mock_request.url.hostname = 'example.com'
mock_request.url.netloc = 'example.com:443'
mock_config = MagicMock()
mock_config.web_url = None
with patch(
'server.utils.url_utils.get_global_config', return_value=mock_config
):
result = get_web_url(mock_request)
assert result == 'https://example.com:443'
def test_unconfigured_web_url_empty_string_fallback(self, mock_request):
"""Empty string web_url should trigger fallback."""
from server.utils.url_utils import get_web_url
mock_request.url.hostname = 'localhost'
mock_request.url.netloc = 'localhost:3000'
mock_config = MagicMock()
mock_config.web_url = ''
with patch(
'server.utils.url_utils.get_global_config', return_value=mock_config
):
result = get_web_url(mock_request)
assert result == 'http://localhost:3000'
class TestGetCookieDomain:
"""Tests for get_cookie_domain function."""
def test_production_with_configured_web_url(self):
"""In production with web_url configured, should return hostname."""
from server.utils.url_utils import get_cookie_domain
mock_config = MagicMock()
mock_config.web_url = 'https://app.all-hands.dev'
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
patch('server.utils.url_utils.IS_STAGING_ENV', False),
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
):
result = get_cookie_domain()
assert result == 'app.all-hands.dev'
def test_production_without_web_url_returns_none(self):
"""In production without web_url configured, should return None."""
from server.utils.url_utils import get_cookie_domain
mock_config = MagicMock()
mock_config.web_url = None
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
patch('server.utils.url_utils.IS_STAGING_ENV', False),
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
):
result = get_cookie_domain()
assert result is None
def test_local_env_returns_none(self):
"""In local environment, should return None for cookie domain."""
from server.utils.url_utils import get_cookie_domain
mock_config = MagicMock()
mock_config.web_url = 'https://app.all-hands.dev'
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
patch('server.utils.url_utils.IS_STAGING_ENV', False),
patch('server.utils.url_utils.IS_LOCAL_ENV', True),
):
result = get_cookie_domain()
assert result is None
def test_staging_env_returns_none(self):
"""In staging environment, should return None for cookie domain."""
from server.utils.url_utils import get_cookie_domain
mock_config = MagicMock()
mock_config.web_url = 'https://staging.all-hands.dev'
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
patch('server.utils.url_utils.IS_STAGING_ENV', True),
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
):
result = get_cookie_domain()
assert result is None
def test_feature_env_returns_none(self):
"""In feature environment, should return None for cookie domain."""
from server.utils.url_utils import get_cookie_domain
mock_config = MagicMock()
mock_config.web_url = 'https://feature-123.staging.all-hands.dev'
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', True),
patch('server.utils.url_utils.IS_STAGING_ENV', True),
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
):
result = get_cookie_domain()
assert result is None
class TestGetCookieSamesite:
"""Tests for get_cookie_samesite function."""
def test_production_with_configured_web_url_returns_strict(self):
"""In production with web_url configured, should return 'strict'."""
from server.utils.url_utils import get_cookie_samesite
mock_config = MagicMock()
mock_config.web_url = 'https://app.all-hands.dev'
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
patch('server.utils.url_utils.IS_STAGING_ENV', False),
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
):
result = get_cookie_samesite()
assert result == 'strict'
def test_production_without_web_url_returns_lax(self):
"""In production without web_url configured, should return 'lax'."""
from server.utils.url_utils import get_cookie_samesite
mock_config = MagicMock()
mock_config.web_url = None
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
patch('server.utils.url_utils.IS_STAGING_ENV', False),
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
):
result = get_cookie_samesite()
assert result == 'lax'
def test_local_env_returns_lax(self):
"""In local environment, should return 'lax'."""
from server.utils.url_utils import get_cookie_samesite
mock_config = MagicMock()
mock_config.web_url = 'http://localhost:3000'
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
patch('server.utils.url_utils.IS_STAGING_ENV', False),
patch('server.utils.url_utils.IS_LOCAL_ENV', True),
):
result = get_cookie_samesite()
assert result == 'lax'
def test_staging_env_returns_lax(self):
"""In staging environment, should return 'lax'."""
from server.utils.url_utils import get_cookie_samesite
mock_config = MagicMock()
mock_config.web_url = 'https://staging.all-hands.dev'
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
patch('server.utils.url_utils.IS_STAGING_ENV', True),
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
):
result = get_cookie_samesite()
assert result == 'lax'
def test_feature_env_returns_lax(self):
"""In feature environment, should return 'lax'."""
from server.utils.url_utils import get_cookie_samesite
mock_config = MagicMock()
mock_config.web_url = 'https://feature-xyz.staging.all-hands.dev'
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', True),
patch('server.utils.url_utils.IS_STAGING_ENV', True),
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
):
result = get_cookie_samesite()
assert result == 'lax'
def test_empty_web_url_returns_lax(self):
"""Empty web_url should be treated as unconfigured and return 'lax'."""
from server.utils.url_utils import get_cookie_samesite
mock_config = MagicMock()
mock_config.web_url = ''
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
patch('server.utils.url_utils.IS_STAGING_ENV', False),
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
):
result = get_cookie_samesite()
assert result == 'lax'
class TestSecurityScenarios:
"""Tests for security-critical scenarios."""
@pytest.fixture
def mock_request(self):
"""Create a mock FastAPI request object."""
request = MagicMock()
request.url = MagicMock()
return request
def test_header_poisoning_attack_blocked_when_configured(self, mock_request):
"""
When web_url is configured, X-Forwarded-* header poisoning should not affect
the returned URL.
"""
from server.utils.url_utils import get_web_url
# Simulate a poisoned request where attacker controls headers
mock_request.url.hostname = 'evil.com'
mock_request.url.netloc = 'evil.com:443'
mock_config = MagicMock()
mock_config.web_url = 'https://app.all-hands.dev'
with patch(
'server.utils.url_utils.get_global_config', return_value=mock_config
):
result = get_web_url(mock_request)
# Should use configured web_url, not the poisoned request data
assert result == 'https://app.all-hands.dev'
assert 'evil' not in result
def test_cookie_domain_not_set_in_dev_environments(self):
"""
Cookie domain should not be set in development environments to prevent
cookies from leaking to other subdomains.
"""
from server.utils.url_utils import get_cookie_domain
mock_config = MagicMock()
mock_config.web_url = 'https://my-feature.staging.all-hands.dev'
# Test each dev environment
for env_name, env_config in [
(
'local',
{
'IS_LOCAL_ENV': True,
'IS_STAGING_ENV': False,
'IS_FEATURE_ENV': False,
},
),
(
'staging',
{
'IS_LOCAL_ENV': False,
'IS_STAGING_ENV': True,
'IS_FEATURE_ENV': False,
},
),
(
'feature',
{'IS_LOCAL_ENV': False, 'IS_STAGING_ENV': True, 'IS_FEATURE_ENV': True},
),
]:
with (
patch(
'server.utils.url_utils.get_global_config', return_value=mock_config
),
patch(
'server.utils.url_utils.IS_FEATURE_ENV',
env_config['IS_FEATURE_ENV'],
),
patch(
'server.utils.url_utils.IS_STAGING_ENV',
env_config['IS_STAGING_ENV'],
),
patch(
'server.utils.url_utils.IS_LOCAL_ENV', env_config['IS_LOCAL_ENV']
),
):
result = get_cookie_domain()
assert result is None, f'Expected None for {env_name} environment'
def test_strict_samesite_only_in_production(self):
"""
SameSite=strict should only be set in production to ensure proper
security without breaking OAuth flows in development.
"""
from server.utils.url_utils import get_cookie_samesite
mock_config = MagicMock()
mock_config.web_url = 'https://app.all-hands.dev'
# Production should be strict
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
patch('server.utils.url_utils.IS_STAGING_ENV', False),
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
):
assert get_cookie_samesite() == 'strict'
# Dev environments should be lax
for env_config in [
{'IS_LOCAL_ENV': True, 'IS_STAGING_ENV': False, 'IS_FEATURE_ENV': False},
{'IS_LOCAL_ENV': False, 'IS_STAGING_ENV': True, 'IS_FEATURE_ENV': False},
{'IS_LOCAL_ENV': False, 'IS_STAGING_ENV': True, 'IS_FEATURE_ENV': True},
]:
with (
patch(
'server.utils.url_utils.get_global_config', return_value=mock_config
),
patch(
'server.utils.url_utils.IS_FEATURE_ENV',
env_config['IS_FEATURE_ENV'],
),
patch(
'server.utils.url_utils.IS_STAGING_ENV',
env_config['IS_STAGING_ENV'],
),
patch(
'server.utils.url_utils.IS_LOCAL_ENV', env_config['IS_LOCAL_ENV']
),
):
assert get_cookie_samesite() == 'lax'

1
frontend/.gitignore vendored
View File

@@ -8,4 +8,3 @@ node_modules/
/blob-report/
/playwright/.cache/
.react-router/
ralph/

View File

@@ -1,5 +1,4 @@
import { describe, expect, it, vi, beforeEach, afterEach, Mock } from "vitest";
import axios from "axios";
import { describe, expect, it, vi } from "vitest";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() }));
@@ -7,8 +6,6 @@ vi.mock("#/api/open-hands-axios", () => ({
openHands: { get: mockGet },
}));
vi.mock("axios");
describe("V1ConversationService", () => {
describe("readConversationFile", () => {
it("uses default plan path when filePath is not provided", async () => {
@@ -27,91 +24,4 @@ describe("V1ConversationService", () => {
);
});
});
describe("uploadFile", () => {
beforeEach(() => {
vi.clearAllMocks();
(axios.post as Mock).mockResolvedValue({ data: {} });
});
afterEach(() => {
vi.resetAllMocks();
});
it("uses query params for file upload path", async () => {
// Arrange
const conversationUrl = "http://localhost:54928/api/conversations/conv-123";
const sessionApiKey = "test-api-key";
const file = new File(["test content"], "test.txt", { type: "text/plain" });
const uploadPath = "/workspace/custom/path.txt";
// Act
await V1ConversationService.uploadFile(
conversationUrl,
sessionApiKey,
file,
uploadPath,
);
// Assert
expect(axios.post).toHaveBeenCalledTimes(1);
const callUrl = (axios.post as Mock).mock.calls[0][0] as string;
// Verify URL uses query params format
expect(callUrl).toContain("/api/file/upload?");
expect(callUrl).toContain("path=%2Fworkspace%2Fcustom%2Fpath.txt");
// Verify it's NOT using path params format
expect(callUrl).not.toContain("/api/file/upload/%2F");
});
it("uses default workspace path when no path provided", async () => {
// Arrange
const conversationUrl = "http://localhost:54928/api/conversations/conv-123";
const sessionApiKey = "test-api-key";
const file = new File(["test content"], "myfile.txt", { type: "text/plain" });
// Act
await V1ConversationService.uploadFile(
conversationUrl,
sessionApiKey,
file,
);
// Assert
expect(axios.post).toHaveBeenCalledTimes(1);
const callUrl = (axios.post as Mock).mock.calls[0][0] as string;
// Default path should be /workspace/{filename}
expect(callUrl).toContain("path=%2Fworkspace%2Fmyfile.txt");
});
it("sends file as FormData with correct headers", async () => {
// Arrange
const conversationUrl = "http://localhost:54928/api/conversations/conv-123";
const sessionApiKey = "test-api-key";
const file = new File(["test content"], "test.txt", { type: "text/plain" });
// Act
await V1ConversationService.uploadFile(
conversationUrl,
sessionApiKey,
file,
);
// Assert
expect(axios.post).toHaveBeenCalledTimes(1);
const callArgs = (axios.post as Mock).mock.calls[0];
// Verify FormData is sent
const formData = callArgs[1];
expect(formData).toBeInstanceOf(FormData);
expect(formData.get("file")).toBe(file);
// Verify headers include session API key and content type
const headers = callArgs[2].headers;
expect(headers).toHaveProperty("X-Session-API-Key", sessionApiKey);
expect(headers).toHaveProperty("Content-Type", "multipart/form-data");
});
});
});

View File

@@ -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");
// @ts-expect-error - partial mock for testing
// @ts-expect-error - We only care about the app_mode and feature_flags fields
getConfigSpy.mockResolvedValue({
app_mode: "saas",
feature_flags: {

View File

@@ -0,0 +1,214 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, 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";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createMockWebClientConfig } from "../../helpers/mock-config";
const mockTrackAddTeamMembersButtonClick = vi.fn();
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackAddTeamMembersButtonClick: mockTrackAddTeamMembersButtonClick,
}),
}));
// Mock posthog feature flag
vi.mock("posthog-js/react", () => ({
useFeatureFlagEnabled: vi.fn(),
}));
// Import the mocked module to get access to the mock
import * as posthog from "posthog-js/react";
describe("AccountSettingsContextMenu", () => {
const user = userEvent.setup();
const onClickAccountSettingsMock = vi.fn();
const onLogoutMock = vi.fn();
const onCloseMock = vi.fn();
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
// Set default feature flag to false
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(false);
});
// Create a wrapper with MemoryRouter and renderWithProviders
const renderWithRouter = (ui: React.ReactElement) => {
return renderWithProviders(<MemoryRouter>{ui}</MemoryRouter>);
};
const renderWithSaasConfig = (ui: React.ReactElement, options?: { analyticsConsent?: boolean }) => {
queryClient.setQueryData(["web-client-config"], createMockWebClientConfig({ app_mode: "saas" }));
queryClient.setQueryData(["settings"], { user_consents_to_analytics: options?.analyticsConsent ?? true });
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>
);
};
const renderWithOssConfig = (ui: React.ReactElement) => {
queryClient.setQueryData(["web-client-config"], createMockWebClientConfig({ app_mode: "oss" }));
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>
);
};
afterEach(() => {
onClickAccountSettingsMock.mockClear();
onLogoutMock.mockClear();
onCloseMock.mockClear();
mockTrackAddTeamMembersButtonClick.mockClear();
vi.mocked(posthog.useFeatureFlagEnabled).mockClear();
});
it("should always render the right options", () => {
renderWithRouter(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
/>,
);
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
expect(screen.getByText("SIDEBAR$DOCS")).toBeInTheDocument();
expect(screen.getByText("ACCOUNT_SETTINGS$LOGOUT")).toBeInTheDocument();
});
it("should render Documentation link with correct attributes", () => {
renderWithRouter(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
/>,
);
const documentationLink = screen.getByText("SIDEBAR$DOCS").closest("a");
expect(documentationLink).toHaveAttribute("href", "https://docs.openhands.dev");
expect(documentationLink).toHaveAttribute("target", "_blank");
expect(documentationLink).toHaveAttribute("rel", "noopener noreferrer");
});
it("should call onLogout when the logout option is clicked", async () => {
renderWithRouter(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
/>,
);
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await user.click(logoutOption);
expect(onLogoutMock).toHaveBeenCalledOnce();
});
test("logout button is always enabled", async () => {
renderWithRouter(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
/>,
);
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await user.click(logoutOption);
expect(onLogoutMock).toHaveBeenCalledOnce();
});
it("should call onClose when clicking outside of the element", async () => {
renderWithRouter(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
/>,
);
const accountSettingsButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await user.click(accountSettingsButton);
await user.click(document.body);
expect(onCloseMock).toHaveBeenCalledOnce();
});
it("should show Add Team Members button in SaaS mode when feature flag is enabled", () => {
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true);
renderWithSaasConfig(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
/>,
);
expect(screen.getByTestId("add-team-members-button")).toBeInTheDocument();
expect(screen.getByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).toBeInTheDocument();
});
it("should not show Add Team Members button in SaaS mode when feature flag is disabled", () => {
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(false);
renderWithSaasConfig(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
/>,
);
expect(screen.queryByTestId("add-team-members-button")).not.toBeInTheDocument();
expect(screen.queryByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).not.toBeInTheDocument();
});
it("should not show Add Team Members button in OSS mode even when feature flag is enabled", () => {
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true);
renderWithOssConfig(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
/>,
);
expect(screen.queryByTestId("add-team-members-button")).not.toBeInTheDocument();
expect(screen.queryByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).not.toBeInTheDocument();
});
it("should not show Add Team Members button when analytics consent is disabled", () => {
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true);
renderWithSaasConfig(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
/>,
{ analyticsConsent: false },
);
expect(screen.queryByTestId("add-team-members-button")).not.toBeInTheDocument();
expect(screen.queryByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).not.toBeInTheDocument();
});
it("should call tracking function and onClose when Add Team Members button is clicked", async () => {
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true);
renderWithSaasConfig(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
/>,
);
const addTeamMembersButton = screen.getByTestId("add-team-members-button");
await user.click(addTeamMembersButton);
expect(mockTrackAddTeamMembersButtonClick).toHaveBeenCalledOnce();
expect(onCloseMock).toHaveBeenCalledOnce();
});
});

View File

@@ -44,7 +44,6 @@ describe("SystemMessage UI Rendering", () => {
<ToolsContextMenu
onClose={() => {}}
onShowSkills={() => {}}
onShowHooks={() => {}}
onShowAgentTools={() => {}}
/>,
);

View File

@@ -15,7 +15,6 @@ vi.mock("#/hooks/use-auth-url", () => ({
bitbucket: "https://bitbucket.org/site/oauth2/authorize",
bitbucket_data_center:
"https://bitbucket-dc.example.com/site/oauth2/authorize",
enterprise_sso: "https://auth.example.com/realms/test/protocol/openid-connect/auth",
};
if (config.appMode === "saas") {
return urls[config.identityProvider] || null;
@@ -118,74 +117,6 @@ describe("LoginContent", () => {
).not.toBeInTheDocument();
});
it("should display Enterprise SSO button when configured", () => {
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/oauth/authorize"
appMode="saas"
authUrl="https://auth.example.com"
providersConfigured={["enterprise_sso"]}
/>
</MemoryRouter>,
);
expect(
screen.getByRole("button", { name: /ENTERPRISE_SSO\$CONNECT_TO_ENTERPRISE_SSO/i }),
).toBeInTheDocument();
});
it("should display Enterprise SSO alongside other providers when all configured", () => {
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/oauth/authorize"
appMode="saas"
authUrl="https://auth.example.com"
providersConfigured={["github", "gitlab", "bitbucket", "enterprise_sso"]}
/>
</MemoryRouter>,
);
expect(
screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "GITLAB$CONNECT_TO_GITLAB" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /BITBUCKET\$CONNECT_TO_BITBUCKET/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /ENTERPRISE_SSO\$CONNECT_TO_ENTERPRISE_SSO/i }),
).toBeInTheDocument();
});
it("should redirect to Enterprise SSO auth URL when Enterprise SSO button is clicked", async () => {
const user = userEvent.setup();
const mockUrl = "https://auth.example.com/realms/test/protocol/openid-connect/auth";
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/oauth/authorize"
appMode="saas"
authUrl="https://auth.example.com"
providersConfigured={["enterprise_sso"]}
/>
</MemoryRouter>,
);
const enterpriseSsoButton = screen.getByRole("button", {
name: /ENTERPRISE_SSO\$CONNECT_TO_ENTERPRISE_SSO/i,
});
await user.click(enterpriseSsoButton);
await waitFor(() => {
expect(window.location.href).toContain(mockUrl);
});
});
it("should display message when no providers are configured", () => {
render(
<MemoryRouter>

View File

@@ -1,207 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { HookEventItem } from "#/components/features/conversation-panel/hook-event-item";
import { HooksEmptyState } from "#/components/features/conversation-panel/hooks-empty-state";
import { HooksLoadingState } from "#/components/features/conversation-panel/hooks-loading-state";
import { HooksModalHeader } from "#/components/features/conversation-panel/hooks-modal-header";
import { HookEvent } from "#/api/conversation-service/v1-conversation-service.types";
// Mock react-i18next
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string, params?: Record<string, unknown>) => {
const translations: Record<string, string> = {
HOOKS_MODAL$TITLE: "Available Hooks",
HOOKS_MODAL$HOOK_COUNT: `${params?.count ?? 0} hooks`,
HOOKS_MODAL$EVENT_PRE_TOOL_USE: "Pre Tool Use",
HOOKS_MODAL$EVENT_POST_TOOL_USE: "Post Tool Use",
HOOKS_MODAL$EVENT_USER_PROMPT_SUBMIT: "User Prompt Submit",
HOOKS_MODAL$EVENT_SESSION_START: "Session Start",
HOOKS_MODAL$EVENT_SESSION_END: "Session End",
HOOKS_MODAL$EVENT_STOP: "Stop",
HOOKS_MODAL$MATCHER: "Matcher",
HOOKS_MODAL$COMMANDS: "Commands",
HOOKS_MODAL$TYPE: `Type: ${params?.type ?? ""}`,
HOOKS_MODAL$TIMEOUT: `Timeout: ${params?.timeout ?? 0}s`,
HOOKS_MODAL$ASYNC: "Async",
COMMON$FETCH_ERROR: "Failed to fetch data",
CONVERSATION$NO_HOOKS: "No hooks configured",
BUTTON$REFRESH: "Refresh",
};
return translations[key] || key;
},
i18n: {
changeLanguage: () => new Promise(() => {}),
},
}),
};
});
describe("HooksLoadingState", () => {
it("should render loading spinner", () => {
render(<HooksLoadingState />);
const spinner = document.querySelector(".animate-spin");
expect(spinner).toBeInTheDocument();
});
});
describe("HooksEmptyState", () => {
it("should render no hooks message when not error", () => {
render(<HooksEmptyState isError={false} />);
expect(screen.getByText("No hooks configured")).toBeInTheDocument();
});
it("should render error message when isError is true", () => {
render(<HooksEmptyState isError={true} />);
expect(screen.getByText("Failed to fetch data")).toBeInTheDocument();
});
});
describe("HooksModalHeader", () => {
const defaultProps = {
isAgentReady: true,
isLoading: false,
isRefetching: false,
onRefresh: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it("should render title", () => {
render(<HooksModalHeader {...defaultProps} />);
expect(screen.getByText("Available Hooks")).toBeInTheDocument();
});
it("should render refresh button when agent is ready", () => {
render(<HooksModalHeader {...defaultProps} />);
expect(screen.getByTestId("refresh-hooks")).toBeInTheDocument();
});
it("should not render refresh button when agent is not ready", () => {
render(<HooksModalHeader {...defaultProps} isAgentReady={false} />);
expect(screen.queryByTestId("refresh-hooks")).not.toBeInTheDocument();
});
it("should call onRefresh when refresh button is clicked", async () => {
const user = userEvent.setup();
const onRefresh = vi.fn();
render(<HooksModalHeader {...defaultProps} onRefresh={onRefresh} />);
await user.click(screen.getByTestId("refresh-hooks"));
expect(onRefresh).toHaveBeenCalledTimes(1);
});
it("should disable refresh button when loading", () => {
render(<HooksModalHeader {...defaultProps} isLoading={true} />);
expect(screen.getByTestId("refresh-hooks")).toBeDisabled();
});
it("should disable refresh button when refetching", () => {
render(<HooksModalHeader {...defaultProps} isRefetching={true} />);
expect(screen.getByTestId("refresh-hooks")).toBeDisabled();
});
});
describe("HookEventItem", () => {
const mockHookEvent: HookEvent = {
event_type: "stop",
matchers: [
{
matcher: "*",
hooks: [
{
type: "command",
command: ".openhands/hooks/on_stop.sh",
timeout: 30,
async: true,
},
],
},
],
};
const defaultProps = {
hookEvent: mockHookEvent,
isExpanded: false,
onToggle: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it("should render event type label using i18n", () => {
render(<HookEventItem {...defaultProps} />);
expect(screen.getByText("Stop")).toBeInTheDocument();
});
it("should render hook count", () => {
render(<HookEventItem {...defaultProps} />);
expect(screen.getByText("1 hooks")).toBeInTheDocument();
});
it("should call onToggle when clicked", async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
render(<HookEventItem {...defaultProps} onToggle={onToggle} />);
await user.click(screen.getByRole("button"));
expect(onToggle).toHaveBeenCalledWith("stop");
});
it("should show collapsed state by default", () => {
render(<HookEventItem {...defaultProps} isExpanded={false} />);
// Matcher content should not be visible when collapsed
expect(screen.queryByText("*")).not.toBeInTheDocument();
});
it("should show expanded state with matcher content", () => {
render(<HookEventItem {...defaultProps} isExpanded={true} />);
// Matcher content should be visible when expanded
expect(screen.getByText("*")).toBeInTheDocument();
});
it("should render async badge for async hooks", () => {
render(<HookEventItem {...defaultProps} isExpanded={true} />);
expect(screen.getByText("Async")).toBeInTheDocument();
});
it("should render different event types with correct i18n labels", () => {
const eventTypes = [
{ type: "pre_tool_use", label: "Pre Tool Use" },
{ type: "post_tool_use", label: "Post Tool Use" },
{ type: "user_prompt_submit", label: "User Prompt Submit" },
{ type: "session_start", label: "Session Start" },
{ type: "session_end", label: "Session End" },
{ type: "stop", label: "Stop" },
];
eventTypes.forEach(({ type, label }) => {
const { unmount } = render(
<HookEventItem
{...defaultProps}
hookEvent={{ ...mockHookEvent, event_type: type }}
/>,
);
expect(screen.getByText(label)).toBeInTheDocument();
unmount();
});
});
it("should fallback to event_type when no i18n key exists", () => {
render(
<HookEventItem
{...defaultProps}
hookEvent={{ ...mockHookEvent, event_type: "unknown_event" }}
/>,
);
expect(screen.getByText("unknown_event")).toBeInTheDocument();
});
});

View File

@@ -72,7 +72,7 @@ vi.mock("react-i18next", async () => {
CONVERSATION$SHOW_SKILLS: "Show Skills",
BUTTON$DISPLAY_COST: "Display Cost",
COMMON$CLOSE_CONVERSATION_STOP_RUNTIME:
"Close Conversation (Stop Sandbox)",
"Close Conversation (Stop Runtime)",
COMMON$DELETE_CONVERSATION: "Delete Conversation",
CONVERSATION$SHARE_PUBLICLY: "Share Publicly",
CONVERSATION$LINK_COPIED: "Link copied to clipboard",
@@ -565,7 +565,7 @@ describe("ConversationNameContextMenu", () => {
"Delete Conversation",
);
expect(screen.getByTestId("stop-button")).toHaveTextContent(
"Close Conversation (Stop Sandbox)",
"Close Conversation (Stop Runtime)",
);
expect(screen.getByTestId("display-cost-button")).toHaveTextContent(
"Display Cost",

View File

@@ -1,118 +0,0 @@
import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { EnterpriseBanner } from "#/components/features/device-verify/enterprise-banner";
const mockCapture = vi.fn();
vi.mock("posthog-js/react", () => ({
usePostHog: () => ({
capture: mockCapture,
}),
}));
const { PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
}));
vi.mock("#/utils/feature-flags", () => ({
PROJ_USER_JOURNEY: () => PROJ_USER_JOURNEY_MOCK(),
}));
describe("EnterpriseBanner", () => {
beforeEach(() => {
vi.clearAllMocks();
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
});
describe("Feature Flag", () => {
it("should not render when proj_user_journey feature flag is disabled", () => {
PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
const { container } = renderWithProviders(<EnterpriseBanner />);
expect(container.firstChild).toBeNull();
expect(screen.queryByText("ENTERPRISE$TITLE")).not.toBeInTheDocument();
});
it("should render when proj_user_journey feature flag is enabled", () => {
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
renderWithProviders(<EnterpriseBanner />);
expect(screen.getByText("ENTERPRISE$TITLE")).toBeInTheDocument();
});
});
describe("Rendering", () => {
it("should render the self-hosted label", () => {
renderWithProviders(<EnterpriseBanner />);
expect(screen.getByText("ENTERPRISE$SELF_HOSTED")).toBeInTheDocument();
});
it("should render the enterprise title", () => {
renderWithProviders(<EnterpriseBanner />);
expect(screen.getByText("ENTERPRISE$TITLE")).toBeInTheDocument();
});
it("should render the enterprise description", () => {
renderWithProviders(<EnterpriseBanner />);
expect(screen.getByText("ENTERPRISE$DESCRIPTION")).toBeInTheDocument();
});
it("should render all four enterprise feature items", () => {
renderWithProviders(<EnterpriseBanner />);
expect(
screen.getByText("ENTERPRISE$FEATURE_DATA_PRIVACY"),
).toBeInTheDocument();
expect(
screen.getByText("ENTERPRISE$FEATURE_DEPLOYMENT"),
).toBeInTheDocument();
expect(screen.getByText("ENTERPRISE$FEATURE_SSO")).toBeInTheDocument();
expect(
screen.getByText("ENTERPRISE$FEATURE_SUPPORT"),
).toBeInTheDocument();
});
it("should render the learn more link", () => {
renderWithProviders(<EnterpriseBanner />);
const link = screen.getByRole("link", {
name: "ENTERPRISE$LEARN_MORE_ARIA",
});
expect(link).toBeInTheDocument();
expect(link).toHaveTextContent("ENTERPRISE$LEARN_MORE");
expect(link).toHaveAttribute("href", "https://openhands.dev/enterprise");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
});
describe("Learn More Link Interaction", () => {
it("should capture PostHog event when learn more link is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<EnterpriseBanner />);
const link = screen.getByRole("link", {
name: "ENTERPRISE$LEARN_MORE_ARIA",
});
await user.click(link);
expect(mockCapture).toHaveBeenCalledWith("saas_selfhosted_inquiry");
});
it("should have correct href attribute for opening in new tab", () => {
renderWithProviders(<EnterpriseBanner />);
const link = screen.getByRole("link", {
name: "ENTERPRISE$LEARN_MORE_ARIA",
});
expect(link).toHaveAttribute("href", "https://openhands.dev/enterprise");
expect(link).toHaveAttribute("target", "_blank");
});
});
});

View File

@@ -1,91 +0,0 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { ConfirmRemoveMemberModal } from "#/components/features/org/confirm-remove-member-modal";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
Trans: ({
values,
components,
}: {
values: { email: string };
components: { email: React.ReactElement };
}) => React.cloneElement(components.email, {}, values.email),
}));
describe("ConfirmRemoveMemberModal", () => {
it("should display the member email in the confirmation message", () => {
// Arrange
const memberEmail = "test@example.com";
// Act
renderWithProviders(
<ConfirmRemoveMemberModal
onConfirm={vi.fn()}
onCancel={vi.fn()}
memberEmail={memberEmail}
/>,
);
// Assert
expect(screen.getByText(memberEmail)).toBeInTheDocument();
});
it("should call onConfirm when the confirm button is clicked", async () => {
// Arrange
const user = userEvent.setup();
const onConfirmMock = vi.fn();
renderWithProviders(
<ConfirmRemoveMemberModal
onConfirm={onConfirmMock}
onCancel={vi.fn()}
memberEmail="test@example.com"
/>,
);
// Act
await user.click(screen.getByTestId("confirm-button"));
// Assert
expect(onConfirmMock).toHaveBeenCalledOnce();
});
it("should call onCancel when the cancel button is clicked", async () => {
// Arrange
const user = userEvent.setup();
const onCancelMock = vi.fn();
renderWithProviders(
<ConfirmRemoveMemberModal
onConfirm={vi.fn()}
onCancel={onCancelMock}
memberEmail="test@example.com"
/>,
);
// Act
await user.click(screen.getByTestId("cancel-button"));
// Assert
expect(onCancelMock).toHaveBeenCalledOnce();
});
it("should disable buttons and show loading spinner when isLoading is true", () => {
// Arrange & Act
renderWithProviders(
<ConfirmRemoveMemberModal
onConfirm={vi.fn()}
onCancel={vi.fn()}
memberEmail="test@example.com"
isLoading
/>,
);
// Assert
expect(screen.getByTestId("confirm-button")).toBeDisabled();
expect(screen.getByTestId("cancel-button")).toBeDisabled();
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
});
});

View File

@@ -1,102 +0,0 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { ConfirmUpdateRoleModal } from "#/components/features/org/confirm-update-role-modal";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
Trans: ({
values,
components,
}: {
values: { email: string; role: string };
components: { email: React.ReactElement; role: React.ReactElement };
}) => (
<>
{React.cloneElement(components.email, {}, values.email)}
{React.cloneElement(components.role, {}, values.role)}
</>
),
}));
describe("ConfirmUpdateRoleModal", () => {
it("should display the member email and new role in the confirmation message", () => {
// Arrange
const memberEmail = "test@example.com";
const newRole = "admin";
// Act
renderWithProviders(
<ConfirmUpdateRoleModal
onConfirm={vi.fn()}
onCancel={vi.fn()}
memberEmail={memberEmail}
newRole={newRole}
/>,
);
// Assert
expect(screen.getByText(memberEmail)).toBeInTheDocument();
expect(screen.getByText(newRole)).toBeInTheDocument();
});
it("should call onConfirm when the confirm button is clicked", async () => {
// Arrange
const user = userEvent.setup();
const onConfirmMock = vi.fn();
renderWithProviders(
<ConfirmUpdateRoleModal
onConfirm={onConfirmMock}
onCancel={vi.fn()}
memberEmail="test@example.com"
newRole="admin"
/>,
);
// Act
await user.click(screen.getByTestId("confirm-button"));
// Assert
expect(onConfirmMock).toHaveBeenCalledOnce();
});
it("should call onCancel when the cancel button is clicked", async () => {
// Arrange
const user = userEvent.setup();
const onCancelMock = vi.fn();
renderWithProviders(
<ConfirmUpdateRoleModal
onConfirm={vi.fn()}
onCancel={onCancelMock}
memberEmail="test@example.com"
newRole="admin"
/>,
);
// Act
await user.click(screen.getByTestId("cancel-button"));
// Assert
expect(onCancelMock).toHaveBeenCalledOnce();
});
it("should disable buttons and show loading spinner when isLoading is true", () => {
// Arrange & Act
renderWithProviders(
<ConfirmUpdateRoleModal
onConfirm={vi.fn()}
onCancel={vi.fn()}
memberEmail="test@example.com"
newRole="admin"
isLoading
/>,
);
// Assert
expect(screen.getByTestId("confirm-button")).toBeDisabled();
expect(screen.getByTestId("cancel-button")).toBeDisabled();
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
});
});

View File

@@ -1,141 +0,0 @@
import { within, screen, render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { organizationService } from "#/api/organization-service/organization-service.api";
import { InviteOrganizationMemberModal } from "#/components/features/org/invite-organization-member-modal";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
vi.mock("react-router", () => ({
useRevalidator: vi.fn(() => ({ revalidate: vi.fn() })),
}));
const renderInviteOrganizationMemberModal = (config?: {
onClose: () => void;
}) =>
render(
<InviteOrganizationMemberModal onClose={config?.onClose || vi.fn()} />,
{
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
},
);
describe("InviteOrganizationMemberModal", () => {
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "1" });
});
afterEach(() => {
vi.clearAllMocks();
useSelectedOrganizationStore.setState({ organizationId: null });
});
it("should call onClose the modal when the close button is clicked", async () => {
const onCloseMock = vi.fn();
renderInviteOrganizationMemberModal({ onClose: onCloseMock });
const modal = screen.getByTestId("invite-modal");
const closeButton = within(modal).getByRole("button", {
name: /close/i,
});
await userEvent.click(closeButton);
expect(onCloseMock).toHaveBeenCalledOnce();
});
it("should call the batch API to invite a single team member when the form is submitted", async () => {
const inviteMembersBatchSpy = vi.spyOn(
organizationService,
"inviteMembers",
);
const onCloseMock = vi.fn();
renderInviteOrganizationMemberModal({ onClose: onCloseMock });
const modal = screen.getByTestId("invite-modal");
const badgeInput = within(modal).getByTestId("emails-badge-input");
await userEvent.type(badgeInput, "someone@acme.org ");
// Verify badge is displayed
expect(screen.getByText("someone@acme.org")).toBeInTheDocument();
const submitButton = within(modal).getByRole("button", {
name: /add/i,
});
await userEvent.click(submitButton);
expect(inviteMembersBatchSpy).toHaveBeenCalledExactlyOnceWith({
orgId: "1",
emails: ["someone@acme.org"],
});
expect(onCloseMock).toHaveBeenCalledOnce();
});
it("should allow adding multiple emails using badge input and make a batch POST request", async () => {
const inviteMembersBatchSpy = vi.spyOn(
organizationService,
"inviteMembers",
);
const onCloseMock = vi.fn();
renderInviteOrganizationMemberModal({ onClose: onCloseMock });
const modal = screen.getByTestId("invite-modal");
// Should have badge input instead of regular input
const badgeInput = within(modal).getByTestId("emails-badge-input");
expect(badgeInput).toBeInTheDocument();
// Add first email by typing and pressing space
await userEvent.type(badgeInput, "user1@acme.org ");
// Add second email by typing and pressing space
await userEvent.type(badgeInput, "user2@acme.org ");
// Add third email by typing and pressing space
await userEvent.type(badgeInput, "user3@acme.org ");
// Verify badges are displayed
expect(screen.getByText("user1@acme.org")).toBeInTheDocument();
expect(screen.getByText("user2@acme.org")).toBeInTheDocument();
expect(screen.getByText("user3@acme.org")).toBeInTheDocument();
const submitButton = within(modal).getByRole("button", {
name: /add/i,
});
await userEvent.click(submitButton);
// Should call batch invite API with all emails
expect(inviteMembersBatchSpy).toHaveBeenCalledExactlyOnceWith({
orgId: "1",
emails: ["user1@acme.org", "user2@acme.org", "user3@acme.org"],
});
expect(onCloseMock).toHaveBeenCalledOnce();
});
it("should display an error toast when clicking add button with no emails added", async () => {
// Arrange
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
const inviteMembersSpy = vi.spyOn(organizationService, "inviteMembers");
renderInviteOrganizationMemberModal();
// Act
const modal = screen.getByTestId("invite-modal");
const submitButton = within(modal).getByRole("button", { name: /add/i });
await userEvent.click(submitButton);
// Assert
expect(displayErrorToastSpy).toHaveBeenCalledWith(
"ORG$NO_EMAILS_ADDED_HINT",
);
expect(inviteMembersSpy).not.toHaveBeenCalled();
});
});

View File

@@ -1,203 +0,0 @@
import { screen, render, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { OrgSelector } from "#/components/features/org/org-selector";
import { organizationService } from "#/api/organization-service/organization-service.api";
import {
MOCK_PERSONAL_ORG,
MOCK_TEAM_ORG_ACME,
createMockOrganization,
} from "#/mocks/org-handlers";
vi.mock("react-router", () => ({
useRevalidator: () => ({ revalidate: vi.fn() }),
useNavigate: () => vi.fn(),
useLocation: () => ({ pathname: "/" }),
useMatch: () => null,
}));
vi.mock("#/hooks/query/use-is-authed", () => ({
useIsAuthed: () => ({ data: true }),
}));
// Mock useConfig to return SaaS mode (organizations are a SaaS-only feature)
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => ({ data: { app_mode: "saas" } }),
}));
vi.mock("react-i18next", async () => {
const actual =
await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"ORG$SELECT_ORGANIZATION_PLACEHOLDER": "Please select an organization",
"ORG$PERSONAL_WORKSPACE": "Personal Workspace",
};
return translations[key] || key;
},
i18n: {
changeLanguage: vi.fn(),
},
}),
};
});
const renderOrgSelector = () =>
render(<OrgSelector />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
describe("OrgSelector", () => {
it("should not render when user only has a personal workspace", async () => {
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_PERSONAL_ORG],
currentOrgId: MOCK_PERSONAL_ORG.id,
});
const { container } = renderOrgSelector();
await waitFor(() => {
expect(container).toBeEmptyDOMElement();
});
});
it("should render when user only has a team organization", async () => {
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_TEAM_ORG_ACME.id,
});
const { container } = renderOrgSelector();
await waitFor(() => {
expect(container).not.toBeEmptyDOMElement();
});
expect(screen.getByRole("combobox")).toBeInTheDocument();
});
it("should show a loading indicator when fetching organizations", () => {
vi.spyOn(organizationService, "getOrganizations").mockImplementation(
() => new Promise(() => {}), // never resolves
);
renderOrgSelector();
// The dropdown trigger should be disabled while loading
const trigger = screen.getByTestId("dropdown-trigger");
expect(trigger).toBeDisabled();
});
it("should select the first organization after orgs are loaded", async () => {
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_PERSONAL_ORG.id,
});
renderOrgSelector();
// The combobox input should show the first org name
await waitFor(() => {
const input = screen.getByRole("combobox");
expect(input).toHaveValue("Personal Workspace");
});
});
it("should show all options when dropdown is opened", async () => {
const user = userEvent.setup();
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [
MOCK_PERSONAL_ORG,
MOCK_TEAM_ORG_ACME,
createMockOrganization("3", "Test Organization", 500),
],
currentOrgId: MOCK_PERSONAL_ORG.id,
});
renderOrgSelector();
// Wait for the selector to be populated with the first organization
await waitFor(() => {
const input = screen.getByRole("combobox");
expect(input).toHaveValue("Personal Workspace");
});
// Click the trigger to open dropdown
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
// Verify all 3 options are visible
const listbox = await screen.findByRole("listbox");
const options = within(listbox).getAllByRole("option");
expect(options).toHaveLength(3);
expect(options[0]).toHaveTextContent("Personal Workspace");
expect(options[1]).toHaveTextContent("Acme Corp");
expect(options[2]).toHaveTextContent("Test Organization");
});
it("should call switchOrganization API when selecting a different organization", async () => {
// Arrange
const user = userEvent.setup();
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_PERSONAL_ORG.id,
});
const switchOrgSpy = vi
.spyOn(organizationService, "switchOrganization")
.mockResolvedValue(MOCK_TEAM_ORG_ACME);
renderOrgSelector();
await waitFor(() => {
expect(screen.getByRole("combobox")).toHaveValue("Personal Workspace");
});
// Act
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
const listbox = await screen.findByRole("listbox");
const acmeOption = within(listbox).getByText("Acme Corp");
await user.click(acmeOption);
// Assert
expect(switchOrgSpy).toHaveBeenCalledWith({ orgId: MOCK_TEAM_ORG_ACME.id });
});
it("should show loading state while switching organizations", async () => {
// Arrange
const user = userEvent.setup();
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_PERSONAL_ORG.id,
});
vi.spyOn(organizationService, "switchOrganization").mockImplementation(
() => new Promise(() => {}), // never resolves to keep loading state
);
renderOrgSelector();
await waitFor(() => {
expect(screen.getByRole("combobox")).toHaveValue("Personal Workspace");
});
// Act
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
const listbox = await screen.findByRole("listbox");
const acmeOption = within(listbox).getByText("Acme Corp");
await user.click(acmeOption);
// Assert
await waitFor(() => {
expect(screen.getByTestId("dropdown-trigger")).toBeDisabled();
});
});
});

View File

@@ -1,109 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { MemoryRouter } from "react-router";
import { SettingsNavigation } from "#/components/features/settings/settings-navigation";
import OptionService from "#/api/option-service/option-service.api";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
import { SAAS_NAV_ITEMS, SettingsNavItem } from "#/constants/settings-nav";
vi.mock("react-router", async () => ({
...(await vi.importActual("react-router")),
useRevalidator: () => ({ revalidate: vi.fn() }),
}));
const mockConfig = () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
app_mode: "saas",
} as Awaited<ReturnType<typeof OptionService.getConfig>>);
};
const ITEMS_WITHOUT_ORG = SAAS_NAV_ITEMS.filter(
(item) =>
item.to !== "/settings/org" && item.to !== "/settings/org-members",
);
const renderSettingsNavigation = (
items: SettingsNavItem[] = SAAS_NAV_ITEMS,
) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<SettingsNavigation
isMobileMenuOpen={false}
onCloseMobileMenu={vi.fn()}
navigationItems={items}
/>
</MemoryRouter>
</QueryClientProvider>,
);
};
describe("SettingsNavigation", () => {
beforeEach(() => {
vi.restoreAllMocks();
mockConfig();
useSelectedOrganizationStore.setState({ organizationId: "org-1" });
});
describe("renders navigation items passed via props", () => {
it("should render org routes when included in navigation items", async () => {
renderSettingsNavigation(SAAS_NAV_ITEMS);
await screen.findByTestId("settings-navbar");
const orgMembersLink = await screen.findByText("Organization Members");
const orgLink = await screen.findByText("Organization");
expect(orgMembersLink).toBeInTheDocument();
expect(orgLink).toBeInTheDocument();
});
it("should not render org routes when excluded from navigation items", async () => {
renderSettingsNavigation(ITEMS_WITHOUT_ORG);
await screen.findByTestId("settings-navbar");
const orgMembersLink = screen.queryByText("Organization Members");
const orgLink = screen.queryByText("Organization");
expect(orgMembersLink).not.toBeInTheDocument();
expect(orgLink).not.toBeInTheDocument();
});
it("should render all non-org SAAS items regardless of which items are passed", async () => {
renderSettingsNavigation(SAAS_NAV_ITEMS);
await screen.findByTestId("settings-navbar");
// Verify non-org items are rendered (using their i18n keys as text since
// react-i18next returns the key when no translation is loaded)
const secretsLink = await screen.findByText("SETTINGS$NAV_SECRETS");
const apiKeysLink = await screen.findByText("SETTINGS$NAV_API_KEYS");
expect(secretsLink).toBeInTheDocument();
expect(apiKeysLink).toBeInTheDocument();
});
it("should render empty nav when given an empty items list", async () => {
renderSettingsNavigation([]);
await screen.findByTestId("settings-navbar");
// No nav links should be rendered
const orgMembersLink = screen.queryByText("Organization Members");
const orgLink = screen.queryByText("Organization");
expect(orgMembersLink).not.toBeInTheDocument();
expect(orgLink).not.toBeInTheDocument();
});
});
});

View File

@@ -1,633 +0,0 @@
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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router";
import { UserContextMenu } from "#/components/features/user/user-context-menu";
import { organizationService } from "#/api/organization-service/organization-service.api";
import { GetComponentPropTypes } from "#/utils/get-component-prop-types";
import {
INITIAL_MOCK_ORGS,
MOCK_PERSONAL_ORG,
MOCK_TEAM_ORG_ACME,
} from "#/mocks/org-handlers";
import AuthService from "#/api/auth-service/auth-service.api";
import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav";
import OptionService from "#/api/option-service/option-service.api";
import { OrganizationMember } from "#/types/org";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
import { createMockWebClientConfig } from "#/mocks/settings-handlers";
type UserContextMenuProps = GetComponentPropTypes<typeof UserContextMenu>;
function UserContextMenuWithRootOutlet({
type,
onClose,
onOpenInviteModal,
}: UserContextMenuProps) {
return (
<div>
<div data-testid="portal-root" id="portal-root" />
<UserContextMenu
type={type}
onClose={onClose}
onOpenInviteModal={onOpenInviteModal}
/>
</div>
);
}
const renderUserContextMenu = ({
type,
onClose,
onOpenInviteModal,
}: UserContextMenuProps) =>
render(
<UserContextMenuWithRootOutlet
type={type}
onClose={onClose}
onOpenInviteModal={onOpenInviteModal}
/>,
{
wrapper: ({ children }) => (
<MemoryRouter>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</MemoryRouter>
),
});
const { navigateMock } = vi.hoisted(() => ({
navigateMock: vi.fn(),
}));
vi.mock("react-router", async (importActual) => ({
...(await importActual()),
useNavigate: () => navigateMock,
useRevalidator: () => ({
revalidate: vi.fn(),
}),
}));
// Mock useIsAuthed to return authenticated state
vi.mock("#/hooks/query/use-is-authed", () => ({
useIsAuthed: () => ({ data: true }),
}));
const createMockUser = (
overrides: Partial<OrganizationMember> = {},
): OrganizationMember => ({
org_id: "org-1",
user_id: "user-1",
email: "test@example.com",
role: "member",
llm_api_key: "",
max_iterations: 100,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "",
status: "active",
...overrides,
});
const seedActiveUser = (user: Partial<OrganizationMember>) => {
useSelectedOrganizationStore.setState({ organizationId: "org-1" });
vi.spyOn(organizationService, "getMe").mockResolvedValue(
createMockUser(user),
);
};
vi.mock("react-i18next", async () => {
const actual =
await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
ORG$SELECT_ORGANIZATION_PLACEHOLDER: "Please select an organization",
ORG$PERSONAL_WORKSPACE: "Personal Workspace",
};
return translations[key] || key;
},
i18n: {
changeLanguage: vi.fn(),
},
}),
};
});
describe("UserContextMenu", () => {
beforeEach(() => {
// Ensure clean state at the start of each test
vi.restoreAllMocks();
useSelectedOrganizationStore.setState({ organizationId: null });
});
afterEach(() => {
vi.restoreAllMocks();
navigateMock.mockClear();
// Reset Zustand store to ensure clean state between tests
useSelectedOrganizationStore.setState({ organizationId: null });
});
it("should render the default context items for a user", () => {
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
screen.getByTestId("org-selector");
screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
expect(
screen.queryByText("ORG$INVITE_ORG_MEMBERS"),
).not.toBeInTheDocument();
expect(
screen.queryByText("ORG$ORGANIZATION_MEMBERS"),
).not.toBeInTheDocument();
expect(
screen.queryByText("COMMON$ORGANIZATION"),
).not.toBeInTheDocument();
});
it("should render navigation items from SAAS_NAV_ITEMS (except organization-members/org)", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({
app_mode: "saas",
feature_flags: {
enable_billing: true,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: false,
hide_billing_page: false,
hide_integrations_page: false,
},
}),
);
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for config to load and verify that navigation items are rendered (except organization-members/org which are filtered out)
const expectedItems = SAAS_NAV_ITEMS.filter(
(item) =>
item.to !== "/settings/org-members" &&
item.to !== "/settings/org" &&
item.to !== "/settings/billing",
);
await waitFor(() => {
expectedItems.forEach((item) => {
expect(screen.getByText(item.text)).toBeInTheDocument();
});
});
});
it("should render navigation items from SAAS_NAV_ITEMS when user role is admin (except organization-members/org)", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({
app_mode: "saas",
feature_flags: {
enable_billing: true,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: false,
hide_billing_page: false,
hide_integrations_page: false,
},
}),
);
seedActiveUser({ role: "admin" });
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for config to load and verify that navigation items are rendered (except organization-members/org which are filtered out)
const expectedItems = SAAS_NAV_ITEMS.filter(
(item) =>
item.to !== "/settings/org-members" && item.to !== "/settings/org",
);
await waitFor(() => {
expectedItems.forEach((item) => {
expect(screen.getByText(item.text)).toBeInTheDocument();
});
});
});
it("should not display Organization Members menu item for regular users (filtered out)", () => {
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Organization Members is filtered out from nav items for all users
expect(screen.queryByText("Organization Members")).not.toBeInTheDocument();
});
it("should render a documentation link", () => {
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
const docsLink = screen.getByText("SIDEBAR$DOCS").closest("a");
expect(docsLink).toHaveAttribute("href", "https://docs.openhands.dev");
expect(docsLink).toHaveAttribute("target", "_blank");
});
describe("OSS mode", () => {
beforeEach(() => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({
app_mode: "oss",
feature_flags: {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: false,
hide_billing_page: false,
hide_integrations_page: false,
},
}),
);
});
it("should render OSS_NAV_ITEMS when in OSS mode", async () => {
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for the config to load and OSS nav items to appear
await waitFor(() => {
OSS_NAV_ITEMS.forEach((item) => {
expect(screen.getByText(item.text)).toBeInTheDocument();
});
});
// Verify SAAS-only items are NOT rendered (e.g., Billing)
expect(
screen.queryByText("SETTINGS$NAV_BILLING"),
).not.toBeInTheDocument();
});
it("should not display Organization Members menu item in OSS mode", async () => {
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for the config to load
await waitFor(() => {
expect(screen.getByText("SETTINGS$NAV_LLM")).toBeInTheDocument();
});
// Verify Organization Members is NOT rendered in OSS mode
expect(
screen.queryByText("Organization Members"),
).not.toBeInTheDocument();
});
});
describe("HIDE_LLM_SETTINGS feature flag", () => {
it("should hide LLM settings link when HIDE_LLM_SETTINGS is true", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({
app_mode: "saas",
feature_flags: {
enable_billing: false,
hide_llm_settings: true,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: false,
hide_billing_page: false,
hide_integrations_page: false,
},
}),
);
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
await waitFor(() => {
// Other nav items should still be visible
expect(screen.getByText("SETTINGS$NAV_USER")).toBeInTheDocument();
// LLM settings (to: "/settings") should NOT be visible
expect(
screen.queryByText("COMMON$LANGUAGE_MODEL_LLM"),
).not.toBeInTheDocument();
});
});
it("should show LLM settings link when HIDE_LLM_SETTINGS is false", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({
app_mode: "saas",
feature_flags: {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: false,
hide_billing_page: false,
hide_integrations_page: false,
},
}),
);
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
await waitFor(() => {
expect(
screen.getByText("COMMON$LANGUAGE_MODEL_LLM"),
).toBeInTheDocument();
});
});
});
it("should render additional context items when user is an admin", () => {
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
screen.getByTestId("org-selector");
screen.getByText("ORG$INVITE_ORG_MEMBERS");
screen.getByText("ORG$ORGANIZATION_MEMBERS");
screen.getByText("COMMON$ORGANIZATION");
});
it("should render additional context items when user is an owner", () => {
renderUserContextMenu({ type: "owner", onClose: vi.fn, onOpenInviteModal: vi.fn });
screen.getByTestId("org-selector");
screen.getByText("ORG$INVITE_ORG_MEMBERS");
screen.getByText("ORG$ORGANIZATION_MEMBERS");
screen.getByText("COMMON$ORGANIZATION");
});
it("should call the logout handler when Logout is clicked", async () => {
const logoutSpy = vi.spyOn(AuthService, "logout");
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
const logoutButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await userEvent.click(logoutButton);
expect(logoutSpy).toHaveBeenCalledOnce();
});
it("should have correct navigation links for nav items", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({
app_mode: "saas",
feature_flags: {
enable_billing: true, // Enable billing so billing link is shown
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: false,
hide_billing_page: false,
hide_integrations_page: false,
},
}),
);
seedActiveUser({ role: "admin" });
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for config to load and test a few representative nav items have the correct href
await waitFor(() => {
const userLink = screen.getByText("SETTINGS$NAV_USER").closest("a");
expect(userLink).toHaveAttribute("href", "/settings/user");
});
await waitFor(() => {
const billingLink = screen.getByText("SETTINGS$NAV_BILLING").closest("a");
expect(billingLink).toHaveAttribute("href", "/settings/billing");
});
await waitFor(() => {
const integrationsLink = screen
.getByText("SETTINGS$NAV_INTEGRATIONS")
.closest("a");
expect(integrationsLink).toHaveAttribute(
"href",
"/settings/integrations",
);
});
});
it("should navigate to /settings/org-members when Manage Organization Members is clicked", async () => {
// Mock a team org so org management buttons are visible (not personal org)
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_TEAM_ORG_ACME.id,
});
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for orgs to load so org management buttons are visible
const manageOrganizationMembersButton = await screen.findByText(
"ORG$ORGANIZATION_MEMBERS",
);
await userEvent.click(manageOrganizationMembersButton);
expect(navigateMock).toHaveBeenCalledExactlyOnceWith(
"/settings/org-members",
);
});
it("should navigate to /settings/org when Manage Account is clicked", async () => {
// Mock a team org so org management buttons are visible (not personal org)
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_TEAM_ORG_ACME.id,
});
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for orgs to load so org management buttons are visible
const manageAccountButton = await screen.findByText(
"COMMON$ORGANIZATION",
);
await userEvent.click(manageAccountButton);
expect(navigateMock).toHaveBeenCalledExactlyOnceWith("/settings/org");
});
it("should call the onClose handler when clicking outside the context menu", async () => {
const onCloseMock = vi.fn();
renderUserContextMenu({ type: "member", onClose: onCloseMock, onOpenInviteModal: vi.fn });
const contextMenu = screen.getByTestId("user-context-menu");
await userEvent.click(contextMenu);
expect(onCloseMock).not.toHaveBeenCalled();
// Simulate clicking outside the context menu
await userEvent.click(document.body);
expect(onCloseMock).toHaveBeenCalled();
});
it("should call the onClose handler after each action", async () => {
// Mock a team org so org management buttons are visible
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_TEAM_ORG_ACME.id,
});
const onCloseMock = vi.fn();
renderUserContextMenu({ type: "owner", onClose: onCloseMock, onOpenInviteModal: vi.fn });
const logoutButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await userEvent.click(logoutButton);
expect(onCloseMock).toHaveBeenCalledTimes(1);
// Wait for orgs to load so org management buttons are visible
const manageOrganizationMembersButton = await screen.findByText(
"ORG$ORGANIZATION_MEMBERS",
);
await userEvent.click(manageOrganizationMembersButton);
expect(onCloseMock).toHaveBeenCalledTimes(2);
const manageAccountButton = screen.getByText("COMMON$ORGANIZATION");
await userEvent.click(manageAccountButton);
expect(onCloseMock).toHaveBeenCalledTimes(3);
});
describe("Personal org vs team org visibility", () => {
it("should not show Organization and Organization Members settings items when personal org is selected", async () => {
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_PERSONAL_ORG],
currentOrgId: MOCK_PERSONAL_ORG.id,
});
vi.spyOn(organizationService, "getMe").mockResolvedValue({
org_id: "1",
user_id: "99",
email: "me@test.com",
role: "admin",
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
});
// Pre-select the personal org in the Zustand store
useSelectedOrganizationStore.setState({ organizationId: "1" });
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for org selector to load and org management buttons to disappear
// (they disappear when personal org is selected)
await waitFor(() => {
expect(
screen.queryByText("ORG$ORGANIZATION_MEMBERS"),
).not.toBeInTheDocument();
});
expect(
screen.queryByText("COMMON$ORGANIZATION"),
).not.toBeInTheDocument();
});
it("should not show Billing settings item when team org is selected", async () => {
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_TEAM_ORG_ACME.id,
});
vi.spyOn(organizationService, "getMe").mockResolvedValue({
org_id: "1",
user_id: "99",
email: "me@test.com",
role: "admin",
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
});
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for org selector to load and billing to disappear
// (billing disappears when team org is selected)
await waitFor(() => {
expect(
screen.queryByText("SETTINGS$NAV_BILLING"),
).not.toBeInTheDocument();
});
});
});
it("should call onOpenInviteModal and onClose when Invite Organization Member is clicked", async () => {
// Mock a team org so org management buttons are visible (not personal org)
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_TEAM_ORG_ACME.id,
});
const onCloseMock = vi.fn();
const onOpenInviteModalMock = vi.fn();
renderUserContextMenu({
type: "admin",
onClose: onCloseMock,
onOpenInviteModal: onOpenInviteModalMock,
});
// Wait for orgs to load so org management buttons are visible
const inviteButton = await screen.findByText("ORG$INVITE_ORG_MEMBERS");
await userEvent.click(inviteButton);
expect(onOpenInviteModalMock).toHaveBeenCalledOnce();
expect(onCloseMock).toHaveBeenCalledOnce();
});
test("the user can change orgs", async () => {
// Mock SaaS mode and organizations for this test
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({
app_mode: "saas",
feature_flags: {
enable_billing: true,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: false,
hide_billing_page: false,
hide_integrations_page: false,
},
}),
);
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: INITIAL_MOCK_ORGS,
currentOrgId: INITIAL_MOCK_ORGS[0].id,
});
const user = userEvent.setup();
const onCloseMock = vi.fn();
renderUserContextMenu({ type: "member", onClose: onCloseMock, onOpenInviteModal: vi.fn });
// Wait for org selector to appear (it may take a moment for config to load)
const orgSelector = await screen.findByTestId("org-selector");
expect(orgSelector).toBeInTheDocument();
// Wait for organizations to load (indicated by org name appearing in the dropdown)
// INITIAL_MOCK_ORGS[0] is a personal org, so it displays "Personal Workspace"
await waitFor(() => {
expect(screen.getByRole("combobox")).toHaveValue("Personal Workspace");
});
// Open the dropdown by clicking the trigger
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
// Select a different organization
const orgOption = screen.getByRole("option", {
name: INITIAL_MOCK_ORGS[1].name,
});
await user.click(orgOption);
expect(onCloseMock).not.toHaveBeenCalled();
// Verify that the dropdown shows the selected organization
expect(screen.getByRole("combobox")).toHaveValue(INITIAL_MOCK_ORGS[1].name);
});
});

View File

@@ -198,9 +198,9 @@ describe("InteractiveChatBox", () => {
expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!", [], []);
});
it("should disable the submit button when awaiting user confirmation", async () => {
it("should disable the submit button when agent is loading", async () => {
const user = userEvent.setup();
mockStores(AgentState.AWAITING_USER_CONFIRMATION);
mockStores(AgentState.LOADING);
renderInteractiveChatBox({
onSubmit: onSubmitMock,

View File

@@ -0,0 +1,219 @@
import { render, screen } from "@testing-library/react";
import { test, expect, describe, vi } from "vitest";
import { useTranslation } from "react-i18next";
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;
}) => (
<div>
{children}
<div>{content}</div>
</div>
),
}));
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];
if (!translationEntry) {
throw new Error(
`Translation key "${key}" does not exist in translation.json`,
);
}
for (const lang of supportedLanguages) {
if (!translationEntry[lang]) {
missingTranslations.push(lang);
}
}
return missingTranslations;
}
// Helper function to find duplicate translation keys
function findDuplicateKeys(obj: Record<string, any>) {
const seen = new Set<string>();
const duplicates = new Set<string>();
// Only check top-level keys as these are our translation keys
for (const key in obj) {
if (seen.has(key)) {
duplicates.add(key);
} else {
seen.add(key);
}
}
return Array.from(duplicates);
}
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => {
const translationEntry = (
translations as Record<string, Record<string, string>>
)[key];
return translationEntry?.ja || key;
},
}),
}));
describe("Landing page translations", () => {
test("should render Japanese translations correctly", () => {
// Mock a simple component that uses the translations
const TestComponent = () => {
const { t } = useTranslation();
return (
<div>
<UserAvatar onClick={() => {}} />
<div data-testid="main-content">
<h1>{t("LANDING$TITLE")}</h1>
<button>{t("VSCODE$OPEN")}</button>
<button>{t("SUGGESTIONS$INCREASE_TEST_COVERAGE")}</button>
<button>{t("SUGGESTIONS$AUTO_MERGE_PRS")}</button>
<button>{t("SUGGESTIONS$FIX_README")}</button>
<button>{t("SUGGESTIONS$CLEAN_DEPENDENCIES")}</button>
</div>
<div data-testid="tabs">
<span>{t("WORKSPACE$TERMINAL_TAB_LABEL")}</span>
<span>{t("WORKSPACE$BROWSER_TAB_LABEL")}</span>
<span>{t("WORKSPACE$JUPYTER_TAB_LABEL")}</span>
<span>{t("WORKSPACE$CODE_EDITOR_TAB_LABEL")}</span>
</div>
<div data-testid="workspace-label">{t("WORKSPACE$TITLE")}</div>
<button data-testid="new-project">{t("PROJECT$NEW_PROJECT")}</button>
<div data-testid="status">
<span>{t("TERMINAL$WAITING_FOR_CLIENT")}</span>
<span>{t("STATUS$CONNECTED")}</span>
<span>{t("STATUS$CONNECTED_TO_SERVER")}</span>
</div>
<div data-testid="time">
<span>{`5 ${t("TIME$MINUTES_AGO")}`}</span>
<span>{`2 ${t("TIME$HOURS_AGO")}`}</span>
<span>{`3 ${t("TIME$DAYS_AGO")}`}</span>
</div>
</div>
);
};
render(<TestComponent />);
// Check main content translations
expect(screen.getByText("開発を始めましょう!")).toBeInTheDocument();
expect(screen.getByText("VS Codeで開く")).toBeInTheDocument();
expect(
screen.getByText("テストカバレッジを向上させる"),
).toBeInTheDocument();
expect(screen.getByText("Dependabot PRを自動マージ")).toBeInTheDocument();
expect(screen.getByText("READMEを改善")).toBeInTheDocument();
expect(screen.getByText("依存関係を整理")).toBeInTheDocument();
// Check tab labels
const tabs = screen.getByTestId("tabs");
expect(tabs).toHaveTextContent("ターミナル");
expect(tabs).toHaveTextContent("ブラウザ");
expect(tabs).toHaveTextContent("Jupyter");
expect(tabs).toHaveTextContent("コードエディタ");
// Check workspace label and new project button
expect(screen.getByTestId("workspace-label")).toHaveTextContent(
"ワークスペース",
);
expect(screen.getByTestId("new-project")).toHaveTextContent(
"新規プロジェクト",
);
// Check status messages
const status = screen.getByTestId("status");
expect(status).toHaveTextContent("クライアントの準備を待機中");
expect(status).toHaveTextContent("接続済み");
expect(status).toHaveTextContent("サーバーに接続済み");
// Check time-related translations
const time = screen.getByTestId("time");
expect(time).toHaveTextContent("5 分前");
expect(time).toHaveTextContent("2 時間前");
expect(time).toHaveTextContent("3 日前");
});
test("all translation keys should have translations for all supported languages", () => {
// Test all translation keys used in the component
const translationKeys = [
"LANDING$TITLE",
"VSCODE$OPEN",
"SUGGESTIONS$INCREASE_TEST_COVERAGE",
"SUGGESTIONS$AUTO_MERGE_PRS",
"SUGGESTIONS$FIX_README",
"SUGGESTIONS$CLEAN_DEPENDENCIES",
"WORKSPACE$TERMINAL_TAB_LABEL",
"WORKSPACE$BROWSER_TAB_LABEL",
"WORKSPACE$JUPYTER_TAB_LABEL",
"WORKSPACE$CODE_EDITOR_TAB_LABEL",
"WORKSPACE$TITLE",
"PROJECT$NEW_PROJECT",
"TERMINAL$WAITING_FOR_CLIENT",
"STATUS$CONNECTED",
"STATUS$CONNECTED_TO_SERVER",
"TIME$MINUTES_AGO",
"TIME$HOURS_AGO",
"TIME$DAYS_AGO",
];
// Check all keys and collect missing translations
const missingTranslationsMap = new Map<string, string[]>();
translationKeys.forEach((key) => {
const missing = checkTranslationExists(key);
if (missing.length > 0) {
missingTranslationsMap.set(key, missing);
}
});
// 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("");
throw new Error(`Missing translations:${errorMessage}`);
}
});
test("translation file should not have duplicate keys", () => {
const duplicates = findDuplicateKeys(translations);
if (duplicates.length > 0) {
throw new Error(
`Found duplicate translation keys: ${duplicates.join(", ")}`,
);
}
});
});

View File

@@ -1,429 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { Dropdown } from "#/ui/dropdown/dropdown";
const mockOptions = [
{ value: "1", label: "Option 1" },
{ value: "2", label: "Option 2" },
{ value: "3", label: "Option 3" },
];
describe("Dropdown", () => {
describe("Trigger", () => {
it("should render a custom trigger button", () => {
render(<Dropdown options={mockOptions} />);
const trigger = screen.getByTestId("dropdown-trigger");
expect(trigger).toBeInTheDocument();
});
it("should open dropdown on trigger click", async () => {
const user = userEvent.setup();
render(<Dropdown options={mockOptions} />);
expect(screen.queryByText("Option 1")).not.toBeInTheDocument();
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
const listbox = screen.getByRole("listbox");
expect(listbox).toBeInTheDocument();
expect(screen.getByText("Option 1")).toBeInTheDocument();
expect(screen.getByText("Option 2")).toBeInTheDocument();
expect(screen.getByText("Option 3")).toBeInTheDocument();
});
});
describe("Type-ahead / Search", () => {
it("should filter options based on input text", async () => {
const user = userEvent.setup();
render(<Dropdown options={mockOptions} />);
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
const input = screen.getByRole("combobox");
await user.type(input, "Option 1");
expect(screen.getByText("Option 1")).toBeInTheDocument();
expect(screen.queryByText("Option 2")).not.toBeInTheDocument();
expect(screen.queryByText("Option 3")).not.toBeInTheDocument();
});
it("should be case-insensitive by default", async () => {
const user = userEvent.setup();
render(<Dropdown options={mockOptions} />);
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
const input = screen.getByRole("combobox");
await user.type(input, "option 1");
expect(screen.getByText("Option 1")).toBeInTheDocument();
expect(screen.queryByText("Option 2")).not.toBeInTheDocument();
});
it("should show all options when search is cleared", async () => {
const user = userEvent.setup();
render(<Dropdown options={mockOptions} />);
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
const input = screen.getByRole("combobox");
await user.type(input, "Option 1");
await user.clear(input);
expect(screen.getByText("Option 1")).toBeInTheDocument();
expect(screen.getByText("Option 2")).toBeInTheDocument();
expect(screen.getByText("Option 3")).toBeInTheDocument();
});
});
describe("Empty state", () => {
it("should display empty state when no options provided", async () => {
const user = userEvent.setup();
render(<Dropdown options={[]} />);
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
expect(screen.getByText("No options")).toBeInTheDocument();
});
it("should render custom empty state message", async () => {
const user = userEvent.setup();
render(<Dropdown options={[]} emptyMessage="Nothing found" />);
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
expect(screen.getByText("Nothing found")).toBeInTheDocument();
});
});
describe("Single selection", () => {
it("should select an option on click", async () => {
const user = userEvent.setup();
render(<Dropdown options={mockOptions} />);
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
const option = screen.getByText("Option 1");
await user.click(option);
expect(screen.getByRole("combobox")).toHaveValue("Option 1");
});
it("should close dropdown after selection", async () => {
const user = userEvent.setup();
render(<Dropdown options={mockOptions} />);
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
await user.click(screen.getByText("Option 1"));
expect(screen.queryByText("Option 2")).not.toBeInTheDocument();
});
it("should display selected option in input", async () => {
const user = userEvent.setup();
render(<Dropdown options={mockOptions} />);
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
await user.click(screen.getByText("Option 1"));
expect(screen.getByRole("combobox")).toHaveValue("Option 1");
});
it("should highlight currently selected option in list", async () => {
const user = userEvent.setup();
render(<Dropdown options={mockOptions} />);
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
await user.click(screen.getByRole("option", { name: "Option 1" }));
await user.click(trigger);
const selectedOption = screen.getByRole("option", { name: "Option 1" });
expect(selectedOption).toHaveAttribute("aria-selected", "true");
});
it("should preserve selected value in input and show all options when reopening dropdown", async () => {
const user = userEvent.setup();
render(<Dropdown options={mockOptions} />);
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
await user.click(screen.getByRole("option", { name: "Option 1" }));
// Reopen the dropdown
await user.click(trigger);
const input = screen.getByRole("combobox");
expect(input).toHaveValue("Option 1");
expect(
screen.getByRole("option", { name: "Option 1" }),
).toBeInTheDocument();
expect(
screen.getByRole("option", { name: "Option 2" }),
).toBeInTheDocument();
expect(
screen.getByRole("option", { name: "Option 3" }),
).toBeInTheDocument();
});
});
describe("Clear button", () => {
it("should not render clear button by default", () => {
render(<Dropdown options={mockOptions} />);
expect(screen.queryByTestId("dropdown-clear")).not.toBeInTheDocument();
});
it("should render clear button when clearable prop is true and has value", async () => {
const user = userEvent.setup();
render(<Dropdown options={mockOptions} clearable />);
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
await user.click(screen.getByRole("option", { name: "Option 1" }));
expect(screen.getByTestId("dropdown-clear")).toBeInTheDocument();
});
it("should clear selection and search input when clear button is clicked", async () => {
const user = userEvent.setup();
render(<Dropdown options={mockOptions} clearable />);
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
await user.click(screen.getByRole("option", { name: "Option 1" }));
const clearButton = screen.getByTestId("dropdown-clear");
await user.click(clearButton);
expect(screen.getByRole("combobox")).toHaveValue("");
});
it("should not render clear button when there is no selection", () => {
render(<Dropdown options={mockOptions} clearable />);
expect(screen.queryByTestId("dropdown-clear")).not.toBeInTheDocument();
});
it("should show placeholder after clearing selection", async () => {
const user = userEvent.setup();
render(
<Dropdown
options={mockOptions}
clearable
placeholder="Select an option"
/>,
);
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
await user.click(screen.getByRole("option", { name: "Option 1" }));
const clearButton = screen.getByTestId("dropdown-clear");
await user.click(clearButton);
const input = screen.getByRole("combobox");
expect(input).toHaveValue("");
});
});
describe("Loading state", () => {
it("should not display loading indicator by default", () => {
render(<Dropdown options={mockOptions} />);
expect(screen.queryByTestId("dropdown-loading")).not.toBeInTheDocument();
});
it("should display loading indicator when loading prop is true", () => {
render(<Dropdown options={mockOptions} loading />);
expect(screen.getByTestId("dropdown-loading")).toBeInTheDocument();
});
it("should disable interaction while loading", async () => {
const user = userEvent.setup();
render(<Dropdown options={mockOptions} loading />);
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
expect(trigger).toHaveAttribute("aria-expanded", "false");
});
});
describe("Disabled state", () => {
it("should not open dropdown when disabled", async () => {
const user = userEvent.setup();
render(<Dropdown options={mockOptions} disabled />);
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
expect(trigger).toHaveAttribute("aria-expanded", "false");
});
it("should have disabled attribute on trigger", () => {
render(<Dropdown options={mockOptions} disabled />);
const trigger = screen.getByTestId("dropdown-trigger");
expect(trigger).toBeDisabled();
});
});
describe("Placeholder", () => {
it("should display placeholder text when no value selected", () => {
render(<Dropdown options={mockOptions} placeholder="Select an option" />);
const input = screen.getByRole("combobox");
expect(input).toHaveAttribute("placeholder", "Select an option");
});
});
describe("Default value", () => {
it("should display defaultValue in input on mount", () => {
render(<Dropdown options={mockOptions} defaultValue={mockOptions[0]} />);
const input = screen.getByRole("combobox");
expect(input).toHaveValue("Option 1");
});
it("should show all options when opened with defaultValue", async () => {
const user = userEvent.setup();
render(<Dropdown options={mockOptions} defaultValue={mockOptions[0]} />);
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
expect(
screen.getByRole("option", { name: "Option 1" }),
).toBeInTheDocument();
expect(
screen.getByRole("option", { name: "Option 2" }),
).toBeInTheDocument();
expect(
screen.getByRole("option", { name: "Option 3" }),
).toBeInTheDocument();
});
it("should restore input value when closed with Escape", async () => {
const user = userEvent.setup();
render(<Dropdown options={mockOptions} defaultValue={mockOptions[0]} />);
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
const input = screen.getByRole("combobox");
await user.type(input, "test");
await user.keyboard("{Escape}");
expect(input).toHaveValue("Option 1");
});
});
describe("onChange", () => {
it("should call onChange with selected item when option is clicked", async () => {
const user = userEvent.setup();
const onChangeMock = vi.fn();
render(<Dropdown options={mockOptions} onChange={onChangeMock} />);
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
await user.click(screen.getByRole("option", { name: "Option 1" }));
expect(onChangeMock).toHaveBeenCalledWith(mockOptions[0]);
});
it("should call onChange with null when selection is cleared", async () => {
const user = userEvent.setup();
const onChangeMock = vi.fn();
render(
<Dropdown
options={mockOptions}
clearable
defaultValue={mockOptions[0]}
onChange={onChangeMock}
/>,
);
const clearButton = screen.getByTestId("dropdown-clear");
await user.click(clearButton);
expect(onChangeMock).toHaveBeenCalledWith(null);
});
});
describe("Controlled mode", () => {
it.todo("should reflect external value changes");
it.todo("should call onChange when selection changes");
it.todo("should not update internal state when controlled");
});
describe("Uncontrolled mode", () => {
it.todo("should manage selection state internally");
it.todo("should call onChange when selection changes");
it.todo("should support defaultValue prop");
});
describe("testId prop", () => {
it("should apply custom testId to the root container", () => {
render(<Dropdown options={mockOptions} testId="org-dropdown" />);
expect(screen.getByTestId("org-dropdown")).toBeInTheDocument();
});
});
describe("Cursor position preservation", () => {
it("should keep menu open when clicking the input while dropdown is open", async () => {
// Without a stateReducer, Downshift's default InputClick behavior
// toggles the menu (closes it if already open). The stateReducer
// should override this to keep the menu open so users can click
// to reposition their cursor without losing the dropdown.
const user = userEvent.setup();
render(<Dropdown options={mockOptions} />);
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
// Menu should be open
expect(screen.getByText("Option 1")).toBeInTheDocument();
// Click the input itself (simulates clicking to reposition cursor)
const input = screen.getByRole("combobox");
await user.click(input);
// Menu should still be open — not toggled closed
expect(screen.getByText("Option 1")).toBeInTheDocument();
});
it("should still filter options correctly after typing with cursor fix", async () => {
// Verifies that the direct onChange handler (which bypasses Downshift's
// default onInputValueChange for cursor preservation) still updates
// the search/filter state correctly.
const user = userEvent.setup();
render(<Dropdown options={mockOptions} />);
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
const input = screen.getByRole("combobox");
await user.type(input, "Option 1");
expect(screen.getByText("Option 1")).toBeInTheDocument();
expect(screen.queryByText("Option 2")).not.toBeInTheDocument();
expect(screen.queryByText("Option 3")).not.toBeInTheDocument();
});
});
});

View File

@@ -1,64 +1,11 @@
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi, afterEach, beforeEach, test } from "vitest";
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 { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { UserActions } from "#/components/features/sidebar/user-actions";
import { MemoryRouter } from "react-router";
import { ReactElement } from "react";
import { UserActions } from "#/components/features/sidebar/user-actions";
import { organizationService } from "#/api/organization-service/organization-service.api";
import { MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME } from "#/mocks/org-handlers";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
import { renderWithProviders } from "../../test-utils";
vi.mock("react-router", async (importActual) => ({
...(await importActual()),
useNavigate: () => vi.fn(),
useRevalidator: () => ({
revalidate: vi.fn(),
}),
}));
vi.mock("react-i18next", async () => {
const actual =
await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
ORG$SELECT_ORGANIZATION_PLACEHOLDER: "Please select an organization",
ORG$PERSONAL_WORKSPACE: "Personal Workspace",
};
return translations[key] || key;
},
i18n: {
changeLanguage: vi.fn(),
},
}),
};
});
const renderUserActions = (props = { hasAvatar: true }) => {
render(
<UserActions
user={
props.hasAvatar
? { avatar_url: "https://example.com/avatar.png" }
: undefined
}
/>,
{
wrapper: ({ children }) => (
<MemoryRouter>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</MemoryRouter>
),
},
);
};
// Create mocks for all the hooks we need
const useIsAuthedMock = vi
.fn()
@@ -91,8 +38,9 @@ describe("UserActions", () => {
const onLogoutMock = vi.fn();
// Create a wrapper with MemoryRouter and renderWithProviders
const renderWithRouter = (ui: ReactElement) =>
renderWithProviders(<MemoryRouter>{ui}</MemoryRouter>);
const renderWithRouter = (ui: ReactElement) => {
return renderWithProviders(<MemoryRouter>{ui}</MemoryRouter>);
};
beforeEach(() => {
// Reset all mocks to default values before each test
@@ -113,11 +61,29 @@ describe("UserActions", () => {
});
it("should render", () => {
renderUserActions();
renderWithRouter(<UserActions onLogout={onLogoutMock} />);
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
});
it("should call onLogout and close the menu when the logout option is clicked", async () => {
renderWithRouter(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await user.click(logoutOption);
expect(onLogoutMock).toHaveBeenCalledOnce();
});
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 });
@@ -130,31 +96,29 @@ describe("UserActions", () => {
providers: [{ id: "github", name: "GitHub" }],
});
renderUserActions();
renderWithRouter(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
// Context menu should NOT appear because user is not authenticated
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
});
it("should NOT show context menu when user is undefined and avatar is hovered", async () => {
renderUserActions({ hasAvatar: false });
const userActions = screen.getByTestId("user-actions");
await user.hover(userActions);
// Context menu should NOT appear because user is undefined
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
});
it("should show context menu even when user has no avatar_url", async () => {
renderUserActions();
const userActions = screen.getByTestId("user-actions");
await user.hover(userActions);
renderWithRouter(
<UserActions onLogout={onLogoutMock} user={{ avatar_url: "" }} />,
);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
// Context menu SHOULD appear because user object exists (even with empty avatar_url)
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
});
it("should NOT be able to access logout when user is not authenticated", async () => {
@@ -169,13 +133,15 @@ describe("UserActions", () => {
providers: [{ id: "github", name: "GitHub" }],
});
renderWithRouter(<UserActions />);
renderWithRouter(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
// Context menu should NOT appear because user is not authenticated
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
// Logout option should NOT be accessible when user is not authenticated
expect(
@@ -195,12 +161,16 @@ describe("UserActions", () => {
providers: [{ id: "github", name: "GitHub" }],
});
const { unmount } = renderWithRouter(<UserActions />);
const { unmount } = renderWithRouter(
<UserActions onLogout={onLogoutMock} />,
);
// Initially no user and not authenticated - menu should not appear
const userActions = screen.getByTestId("user-actions");
await user.hover(userActions);
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
let userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
// Unmount the first component
unmount();
@@ -218,7 +188,10 @@ describe("UserActions", () => {
// Render a new component with user prop and authentication
renderWithRouter(
<UserActions user={{ avatar_url: "https://example.com/avatar.png" }} />,
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
);
// Component should render correctly
@@ -226,10 +199,12 @@ describe("UserActions", () => {
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
// Menu should now work with user defined and authenticated
const userActionsEl = screen.getByTestId("user-actions");
await user.hover(userActionsEl);
userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
});
it("should handle user prop changing from defined to undefined", async () => {
@@ -244,13 +219,18 @@ describe("UserActions", () => {
});
const { rerender } = renderWithRouter(
<UserActions user={{ avatar_url: "https://example.com/avatar.png" }} />,
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
);
// Hover to open menu
const userActions = screen.getByTestId("user-actions");
await user.hover(userActions);
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
// Click to open menu
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
// Set authentication to false for the rerender
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
@@ -266,12 +246,14 @@ describe("UserActions", () => {
// Remove user prop - menu should disappear because user is no longer authenticated
rerender(
<MemoryRouter>
<UserActions />
<UserActions onLogout={onLogoutMock} />
</MemoryRouter>,
);
// Context menu should NOT be visible when user becomes unauthenticated
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
// Logout option should not be accessible
expect(
@@ -290,168 +272,20 @@ describe("UserActions", () => {
providers: [{ id: "github", name: "GitHub" }],
});
renderUserActions();
const userActions = screen.getByTestId("user-actions");
await user.hover(userActions);
renderWithRouter(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
isLoading={true}
/>,
);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
// Context menu should still appear even when loading
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
});
test("context menu should default to user role", async () => {
renderUserActions();
const userActions = screen.getByTestId("user-actions");
await user.hover(userActions);
// Verify logout is present
expect(screen.getByTestId("user-context-menu")).toHaveTextContent(
"ACCOUNT_SETTINGS$LOGOUT",
);
// Verify nav items are present (e.g., settings nav items)
expect(screen.getByTestId("user-context-menu")).toHaveTextContent(
"SETTINGS$NAV_USER",
);
// Verify admin-only items are NOT present for user role
expect(
screen.queryByText("ORG$MANAGE_ORGANIZATION_MEMBERS"),
).not.toBeInTheDocument();
expect(
screen.queryByText("ORG$MANAGE_ORGANIZATION"),
).not.toBeInTheDocument();
});
test("should NOT show Team and Organization nav items when personal workspace is selected", async () => {
renderUserActions();
const userActions = screen.getByTestId("user-actions");
await user.hover(userActions);
// Team and Organization nav links should NOT be visible when no org is selected (personal workspace)
expect(screen.queryByText("Team")).not.toBeInTheDocument();
expect(screen.queryByText("Organization")).not.toBeInTheDocument();
});
it("should show context menu on hover", async () => {
renderUserActions();
const userActions = screen.getByTestId("user-actions");
const contextMenu = screen.getByTestId("user-context-menu");
// Menu is in DOM but hidden via CSS (opacity-0, pointer-events-none)
expect(contextMenu.parentElement).toHaveClass("opacity-0");
expect(contextMenu.parentElement).toHaveClass("pointer-events-none");
// Hover over the user actions area
await user.hover(userActions);
// Menu should be visible on hover (CSS classes change via group-hover)
expect(contextMenu).toBeVisible();
});
it("should have pointer-events-none on hover bridge pseudo-element to allow menu item clicks", async () => {
renderUserActions();
const userActions = screen.getByTestId("user-actions");
await user.hover(userActions);
const contextMenu = screen.getByTestId("user-context-menu");
const hoverBridgeContainer = contextMenu.parentElement;
// The hover bridge uses a ::before pseudo-element for diagonal mouse movement
// This pseudo-element MUST have pointer-events-none to allow clicks through to menu items
// The class should include "before:pointer-events-none" to prevent the hover bridge from blocking clicks
expect(hoverBridgeContainer?.className).toContain(
"before:pointer-events-none",
);
});
describe("Org selector dropdown state reset when context menu hides", () => {
// These tests verify that the org selector dropdown resets its internal
// state (search text, open/closed) when the context menu hides and
// reappears. Without this, stale state persists because the context
// menu is hidden via CSS (opacity/pointer-events) rather than unmounted.
beforeEach(() => {
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_PERSONAL_ORG.id,
});
useSelectedOrganizationStore.setState({ organizationId: null });
});
it("should reset org selector search text when context menu hides and reappears", async () => {
renderUserActions();
const userActions = screen.getByTestId("user-actions");
// Hover to show context menu
await user.hover(userActions);
// Wait for orgs to load and auto-select
await waitFor(() => {
expect(screen.getByRole("combobox")).toHaveValue(
MOCK_PERSONAL_ORG.name,
);
});
// Open dropdown and type search text
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
const input = screen.getByRole("combobox");
await user.clear(input);
await user.type(input, "search text");
expect(input).toHaveValue("search text");
// Unhover to hide context menu, then hover again
await user.unhover(userActions);
await user.hover(userActions);
// Org selector should be reset — showing selected org name, not search text
await waitFor(() => {
expect(screen.getByRole("combobox")).toHaveValue(
MOCK_PERSONAL_ORG.name,
);
});
});
it("should reset dropdown to collapsed state when context menu hides and reappears", async () => {
renderUserActions();
const userActions = screen.getByTestId("user-actions");
// Hover to show context menu
await user.hover(userActions);
// Wait for orgs to load
await waitFor(() => {
expect(screen.getByRole("combobox")).toHaveValue(
MOCK_PERSONAL_ORG.name,
);
});
// Open dropdown and type to change its state
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
const input = screen.getByRole("combobox");
await user.clear(input);
await user.type(input, "Acme");
expect(input).toHaveValue("Acme");
// Unhover to hide context menu, then hover again
await user.unhover(userActions);
await user.hover(userActions);
// Wait for fresh component with org data
await waitFor(() => {
expect(screen.getByRole("combobox")).toHaveValue(
MOCK_PERSONAL_ORG.name,
);
});
// Dropdown should be collapsed (closed) after reset
expect(screen.getByTestId("dropdown-trigger")).toHaveAttribute(
"aria-expanded",
"false",
);
// No option elements should be rendered
expect(screen.queryAllByRole("option")).toHaveLength(0);
});
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
});
});

View File

@@ -1,18 +1,40 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { UserAvatar } from "#/components/features/sidebar/user-avatar";
describe("UserAvatar", () => {
const onClickMock = vi.fn();
afterEach(() => {
onClickMock.mockClear();
});
it("(default) should render the placeholder avatar when the user is logged out", () => {
render(<UserAvatar />);
render(<UserAvatar onClick={onClickMock} />);
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
expect(
screen.getByLabelText("USER$AVATAR_PLACEHOLDER"),
).toBeInTheDocument();
});
it("should call onClick when clicked", async () => {
const user = userEvent.setup();
render(<UserAvatar onClick={onClickMock} />);
const userAvatarContainer = screen.getByTestId("user-avatar");
await user.click(userAvatarContainer);
expect(onClickMock).toHaveBeenCalledOnce();
});
it("should display the user's avatar when available", () => {
render(<UserAvatar avatarUrl="https://example.com/avatar.png" />);
render(
<UserAvatar
onClick={onClickMock}
avatarUrl="https://example.com/avatar.png"
/>,
);
expect(screen.getByAltText("AVATAR$ALT_TEXT")).toBeInTheDocument();
expect(
@@ -21,20 +43,24 @@ describe("UserAvatar", () => {
});
it("should display a loading spinner instead of an avatar when isLoading is true", () => {
const { rerender } = render(<UserAvatar />);
const { rerender } = render(<UserAvatar onClick={onClickMock} />);
expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
expect(
screen.getByLabelText("USER$AVATAR_PLACEHOLDER"),
).toBeInTheDocument();
rerender(<UserAvatar isLoading />);
rerender(<UserAvatar onClick={onClickMock} isLoading />);
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
expect(
screen.queryByLabelText("USER$AVATAR_PLACEHOLDER"),
).not.toBeInTheDocument();
rerender(
<UserAvatar avatarUrl="https://example.com/avatar.png" isLoading />,
<UserAvatar
onClick={onClickMock}
avatarUrl="https://example.com/avatar.png"
isLoading
/>,
);
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
expect(screen.queryByAltText("AVATAR$ALT_TEXT")).not.toBeInTheDocument();

View File

@@ -1,11 +1,7 @@
import { describe, it, expect } from "vitest";
import { getObservationContent } from "#/components/v1/chat/event-content-helpers/get-observation-content";
import { ObservationEvent } from "#/types/v1/core";
import {
BrowserObservation,
GlobObservation,
GrepObservation,
} from "#/types/v1/core/base/observation";
import { BrowserObservation } from "#/types/v1/core/base/observation";
describe("getObservationContent - BrowserObservation", () => {
it("should return output content when available", () => {
@@ -94,212 +90,3 @@ describe("getObservationContent - BrowserObservation", () => {
expect(result).toBe("**Output:**\nPage loaded successfully");
});
});
describe("getObservationContent - GlobObservation", () => {
it("should display files found when glob matches files", () => {
// Arrange
const mockEvent: ObservationEvent<GlobObservation> = {
id: "test-id",
timestamp: "2024-01-01T00:00:00Z",
source: "environment",
tool_name: "glob",
tool_call_id: "call-id",
action_id: "action-id",
observation: {
kind: "GlobObservation",
content: [{ type: "text", text: "Found 2 files", cache_prompt: false }],
is_error: false,
files: ["/workspace/src/index.ts", "/workspace/src/app.ts"],
pattern: "**/*.ts",
search_path: "/workspace",
truncated: false,
},
};
// Act
const result = getObservationContent(mockEvent);
// Assert
expect(result).toContain("**Pattern:** `**/*.ts`");
expect(result).toContain("**Search Path:** `/workspace`");
expect(result).toContain("**Files Found (2):**");
expect(result).toContain("- `/workspace/src/index.ts`");
expect(result).toContain("- `/workspace/src/app.ts`");
});
it("should display no files found message when glob matches nothing", () => {
// Arrange
const mockEvent: ObservationEvent<GlobObservation> = {
id: "test-id",
timestamp: "2024-01-01T00:00:00Z",
source: "environment",
tool_name: "glob",
tool_call_id: "call-id",
action_id: "action-id",
observation: {
kind: "GlobObservation",
content: [{ type: "text", text: "No files found", cache_prompt: false }],
is_error: false,
files: [],
pattern: "**/*.xyz",
search_path: "/workspace",
truncated: false,
},
};
// Act
const result = getObservationContent(mockEvent);
// Assert
expect(result).toContain("**Pattern:** `**/*.xyz`");
expect(result).toContain("**Result:** No files found.");
});
it("should display error when glob operation fails", () => {
// Arrange
const mockEvent: ObservationEvent<GlobObservation> = {
id: "test-id",
timestamp: "2024-01-01T00:00:00Z",
source: "environment",
tool_name: "glob",
tool_call_id: "call-id",
action_id: "action-id",
observation: {
kind: "GlobObservation",
content: [{ type: "text", text: "Permission denied", cache_prompt: false }],
is_error: true,
files: [],
pattern: "**/*",
search_path: "/restricted",
truncated: false,
},
};
// Act
const result = getObservationContent(mockEvent);
// Assert
expect(result).toContain("**Error:**");
expect(result).toContain("Permission denied");
});
it("should indicate truncation when results exceed limit", () => {
// Arrange
const mockEvent: ObservationEvent<GlobObservation> = {
id: "test-id",
timestamp: "2024-01-01T00:00:00Z",
source: "environment",
tool_name: "glob",
tool_call_id: "call-id",
action_id: "action-id",
observation: {
kind: "GlobObservation",
content: [{ type: "text", text: "Found files", cache_prompt: false }],
is_error: false,
files: ["/workspace/file1.ts"],
pattern: "**/*.ts",
search_path: "/workspace",
truncated: true,
},
};
// Act
const result = getObservationContent(mockEvent);
// Assert
expect(result).toContain("**Files Found (1+, truncated):**");
});
});
describe("getObservationContent - GrepObservation", () => {
it("should display matches found when grep finds results", () => {
// Arrange
const mockEvent: ObservationEvent<GrepObservation> = {
id: "test-id",
timestamp: "2024-01-01T00:00:00Z",
source: "environment",
tool_name: "grep",
tool_call_id: "call-id",
action_id: "action-id",
observation: {
kind: "GrepObservation",
content: [{ type: "text", text: "Found 2 matches", cache_prompt: false }],
is_error: false,
matches: ["/workspace/src/api.ts", "/workspace/src/routes.ts"],
pattern: "fetchData",
search_path: "/workspace",
include_pattern: "*.ts",
truncated: false,
},
};
// Act
const result = getObservationContent(mockEvent);
// Assert
expect(result).toContain("**Pattern:** `fetchData`");
expect(result).toContain("**Search Path:** `/workspace`");
expect(result).toContain("**Include:** `*.ts`");
expect(result).toContain("**Matches (2):**");
expect(result).toContain("- `/workspace/src/api.ts`");
});
it("should display no matches found when grep finds nothing", () => {
// Arrange
const mockEvent: ObservationEvent<GrepObservation> = {
id: "test-id",
timestamp: "2024-01-01T00:00:00Z",
source: "environment",
tool_name: "grep",
tool_call_id: "call-id",
action_id: "action-id",
observation: {
kind: "GrepObservation",
content: [{ type: "text", text: "No matches", cache_prompt: false }],
is_error: false,
matches: [],
pattern: "nonExistentFunction",
search_path: "/workspace",
include_pattern: null,
truncated: false,
},
};
// Act
const result = getObservationContent(mockEvent);
// Assert
expect(result).toContain("**Pattern:** `nonExistentFunction`");
expect(result).toContain("**Result:** No matches found.");
expect(result).not.toContain("**Include:**");
});
it("should display error when grep operation fails", () => {
// Arrange
const mockEvent: ObservationEvent<GrepObservation> = {
id: "test-id",
timestamp: "2024-01-01T00:00:00Z",
source: "environment",
tool_name: "grep",
tool_call_id: "call-id",
action_id: "action-id",
observation: {
kind: "GrepObservation",
content: [{ type: "text", text: "Invalid regex pattern", cache_prompt: false }],
is_error: true,
matches: [],
pattern: "[invalid",
search_path: "/workspace",
include_pattern: null,
truncated: false,
},
};
// Act
const result = getObservationContent(mockEvent);
// Assert
expect(result).toContain("**Error:**");
expect(result).toContain("Invalid regex pattern");
});
});

View File

@@ -28,7 +28,6 @@ const createUserMessageEvent = (id: string): MessageEvent => ({
const createPlanningObservationEvent = (
id: string,
actionId: string = "action-1",
path: string = "/workspace/PLAN.md",
): ObservationEvent<PlanningFileEditorObservation> => ({
id,
timestamp: new Date().toISOString(),
@@ -41,7 +40,7 @@ const createPlanningObservationEvent = (
content: [{ type: "text", text: "Plan content" }],
is_error: false,
command: "create",
path,
path: "/workspace/PLAN.md",
prev_exist: false,
old_content: null,
new_content: "Plan content",
@@ -173,31 +172,6 @@ describe("usePlanPreviewEvents", () => {
expect(result.current.size).toBe(1);
expect(result.current.has("plan-obs-1")).toBe(true);
});
it("should exclude PlanningFileEditorObservation for non-Plan.md paths", () => {
const events: OpenHandsEvent[] = [
createUserMessageEvent("user-1"),
createPlanningObservationEvent("plan-obs-1", "action-1", "settings.py"),
createPlanningObservationEvent("plan-obs-2", "action-2", "use-add-mcp.ts"),
];
const { result } = renderHook(() => usePlanPreviewEvents(events));
expect(result.current.size).toBe(0);
});
it("should include only Plan.md observations when mixed with other file edits", () => {
const events: OpenHandsEvent[] = [
createUserMessageEvent("user-1"),
createPlanningObservationEvent("plan-obs-1", "action-1", "settings.py"),
createPlanningObservationEvent("plan-obs-2", "action-2", "/workspace/PLAN.md"),
];
const { result } = renderHook(() => usePlanPreviewEvents(events));
expect(result.current.size).toBe(1);
expect(result.current.has("plan-obs-2")).toBe(true);
});
});
describe("shouldShowPlanPreview", () => {

View File

@@ -229,231 +229,4 @@ describe("conversation localStorage utilities", () => {
expect(parsed.subConversationTaskId).toBeNull();
});
});
describe("draftMessage persistence", () => {
describe("getConversationState", () => {
it("returns default draftMessage as null when no state exists", () => {
// Arrange
const conversationId = "conv-draft-1";
// Act
const state = getConversationState(conversationId);
// Assert
expect(state.draftMessage).toBeNull();
});
it("retrieves draftMessage from localStorage when it exists", () => {
// Arrange
const conversationId = "conv-draft-2";
const draftText = "This is my saved draft message";
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
localStorage.setItem(
consolidatedKey,
JSON.stringify({
draftMessage: draftText,
}),
);
// Act
const state = getConversationState(conversationId);
// Assert
expect(state.draftMessage).toBe(draftText);
});
it("returns null draftMessage for task conversation IDs (not persisted)", () => {
// Arrange
const taskId = "task-uuid-123";
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${taskId}`;
// Even if somehow there's data in localStorage for a task ID
localStorage.setItem(
consolidatedKey,
JSON.stringify({
draftMessage: "Should not be returned",
}),
);
// Act
const state = getConversationState(taskId);
// Assert - should return default state, not the stored value
expect(state.draftMessage).toBeNull();
});
});
describe("setConversationState", () => {
it("persists draftMessage to localStorage", () => {
// Arrange
const conversationId = "conv-draft-3";
const draftText = "New draft message to save";
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
// Act
setConversationState(conversationId, {
draftMessage: draftText,
});
// Assert
const stored = localStorage.getItem(consolidatedKey);
expect(stored).not.toBeNull();
const parsed = JSON.parse(stored!);
expect(parsed.draftMessage).toBe(draftText);
});
it("does not persist draftMessage for task conversation IDs", () => {
// Arrange
const taskId = "task-draft-xyz";
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${taskId}`;
// Act
setConversationState(taskId, {
draftMessage: "Draft for task ID",
});
// Assert - nothing should be stored
expect(localStorage.getItem(consolidatedKey)).toBeNull();
});
it("merges draftMessage with existing state without overwriting other fields", () => {
// Arrange
const conversationId = "conv-draft-4";
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
localStorage.setItem(
consolidatedKey,
JSON.stringify({
selectedTab: "terminal",
rightPanelShown: false,
unpinnedTabs: ["tab-1", "tab-2"],
conversationMode: "plan",
subConversationTaskId: "task-123",
}),
);
// Act
setConversationState(conversationId, {
draftMessage: "Updated draft",
});
// Assert
const stored = localStorage.getItem(consolidatedKey);
const parsed = JSON.parse(stored!);
expect(parsed.draftMessage).toBe("Updated draft");
expect(parsed.selectedTab).toBe("terminal");
expect(parsed.rightPanelShown).toBe(false);
expect(parsed.unpinnedTabs).toEqual(["tab-1", "tab-2"]);
expect(parsed.conversationMode).toBe("plan");
expect(parsed.subConversationTaskId).toBe("task-123");
});
it("clears draftMessage when set to null", () => {
// Arrange
const conversationId = "conv-draft-5";
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
localStorage.setItem(
consolidatedKey,
JSON.stringify({
draftMessage: "Existing draft",
}),
);
// Act
setConversationState(conversationId, {
draftMessage: null,
});
// Assert
const stored = localStorage.getItem(consolidatedKey);
const parsed = JSON.parse(stored!);
expect(parsed.draftMessage).toBeNull();
});
it("clears draftMessage when set to empty string (stored as empty string)", () => {
// Arrange
const conversationId = "conv-draft-6";
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
localStorage.setItem(
consolidatedKey,
JSON.stringify({
draftMessage: "Existing draft",
}),
);
// Act
setConversationState(conversationId, {
draftMessage: "",
});
// Assert
const stored = localStorage.getItem(consolidatedKey);
const parsed = JSON.parse(stored!);
expect(parsed.draftMessage).toBe("");
});
});
describe("conversation-specific draft isolation", () => {
it("stores drafts separately for different conversations", () => {
// Arrange
const convA = "conv-A";
const convB = "conv-B";
const draftA = "Draft for conversation A";
const draftB = "Draft for conversation B";
// Act
setConversationState(convA, { draftMessage: draftA });
setConversationState(convB, { draftMessage: draftB });
// Assert
const stateA = getConversationState(convA);
const stateB = getConversationState(convB);
expect(stateA.draftMessage).toBe(draftA);
expect(stateB.draftMessage).toBe(draftB);
});
it("updating one conversation draft does not affect another", () => {
// Arrange
const convA = "conv-isolated-A";
const convB = "conv-isolated-B";
setConversationState(convA, { draftMessage: "Original draft A" });
setConversationState(convB, { draftMessage: "Original draft B" });
// Act - update only conversation A
setConversationState(convA, { draftMessage: "Updated draft A" });
// Assert - conversation B should be unchanged
const stateA = getConversationState(convA);
const stateB = getConversationState(convB);
expect(stateA.draftMessage).toBe("Updated draft A");
expect(stateB.draftMessage).toBe("Original draft B");
});
it("clearing one conversation draft does not affect another", () => {
// Arrange
const convA = "conv-clear-A";
const convB = "conv-clear-B";
setConversationState(convA, { draftMessage: "Draft A" });
setConversationState(convB, { draftMessage: "Draft B" });
// Act - clear draft for conversation A
setConversationState(convA, { draftMessage: null });
// Assert
const stateA = getConversationState(convA);
const stateB = getConversationState(convB);
expect(stateA.draftMessage).toBeNull();
expect(stateB.draftMessage).toBe("Draft B");
});
});
});
});

View File

@@ -1,4 +1,3 @@
import React from "react";
import {
describe,
it,
@@ -9,7 +8,7 @@ import {
afterEach,
vi,
} from "vitest";
import { screen, waitFor, render, cleanup, act } from "@testing-library/react";
import { screen, waitFor, render, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
@@ -683,242 +682,8 @@ describe("Conversation WebSocket Handler", () => {
// 7. Message Sending Tests
describe("Message Sending", () => {
it("should send user actions through WebSocket when connected", async () => {
// Arrange
const conversationId = "test-conversation-send";
let receivedMessage: unknown = null;
// Set up MSW to capture sent messages
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
// Capture messages sent from client
client.addEventListener("message", (event) => {
receivedMessage = JSON.parse(event.data as string);
});
}),
);
// Create ref to store sendMessage function
let sendMessageFn: typeof useConversationWebSocket extends () => infer R
? R extends { sendMessage: infer S }
? S
: null
: null = null;
function TestComponent() {
const context = useConversationWebSocket();
React.useEffect(() => {
if (context?.sendMessage) {
sendMessageFn = context.sendMessage;
}
}, [context?.sendMessage]);
return (
<div>
<div data-testid="connection-state">
{context?.connectionState || "NOT_AVAILABLE"}
</div>
</div>
);
}
// Act
renderWithWebSocketContext(
<TestComponent />,
conversationId,
`http://localhost:3000/api/conversations/${conversationId}`,
);
// Wait for connection
await waitFor(() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"OPEN",
);
});
// Send a message
await waitFor(() => {
expect(sendMessageFn).not.toBeNull();
});
await act(async () => {
await sendMessageFn!({
role: "user",
content: [{ type: "text", text: "Hello from test" }],
});
});
// Assert - message should have been received by mock server
await waitFor(() => {
expect(receivedMessage).toEqual({
role: "user",
content: [{ type: "text", text: "Hello from test" }],
});
});
});
it("should not throw error when sendMessage is called with WebSocket connected", async () => {
// This test verifies that sendMessage doesn't throw an error
// when the WebSocket is connected.
const conversationId = "test-conversation-no-throw";
let sendError: Error | null = null;
// Set up MSW to connect and receive messages
mswServer.use(
wsLink.addEventListener("connection", ({ server }) => {
server.connect();
}),
);
// Create ref to store sendMessage function
let sendMessageFn: typeof useConversationWebSocket extends () => infer R
? R extends { sendMessage: infer S }
? S
: null
: null = null;
function TestComponent() {
const context = useConversationWebSocket();
React.useEffect(() => {
if (context?.sendMessage) {
sendMessageFn = context.sendMessage;
}
}, [context?.sendMessage]);
return (
<div>
<div data-testid="connection-state">
{context?.connectionState || "NOT_AVAILABLE"}
</div>
</div>
);
}
// Act
renderWithWebSocketContext(
<TestComponent />,
conversationId,
`http://localhost:3000/api/conversations/${conversationId}`,
);
// Wait for connection
await waitFor(() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"OPEN",
);
});
// Wait for the context to be available
await waitFor(() => {
expect(sendMessageFn).not.toBeNull();
});
// Try to send a message
await act(async () => {
try {
await sendMessageFn!({
role: "user",
content: [{ type: "text", text: "Test message" }],
});
} catch (error) {
sendError = error as Error;
}
});
// Assert - should NOT throw an error
expect(sendError).toBeNull();
});
it("should send multiple messages through WebSocket in order", async () => {
// Arrange
const conversationId = "test-conversation-multi";
const receivedMessages: unknown[] = [];
// Set up MSW to capture sent messages
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
// Capture messages sent from client
client.addEventListener("message", (event) => {
receivedMessages.push(JSON.parse(event.data as string));
});
}),
);
// Create ref to store sendMessage function
let sendMessageFn: typeof useConversationWebSocket extends () => infer R
? R extends { sendMessage: infer S }
? S
: null
: null = null;
function TestComponent() {
const context = useConversationWebSocket();
React.useEffect(() => {
if (context?.sendMessage) {
sendMessageFn = context.sendMessage;
}
}, [context?.sendMessage]);
return (
<div>
<div data-testid="connection-state">
{context?.connectionState || "NOT_AVAILABLE"}
</div>
</div>
);
}
// Act
renderWithWebSocketContext(
<TestComponent />,
conversationId,
`http://localhost:3000/api/conversations/${conversationId}`,
);
// Wait for connection
await waitFor(() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"OPEN",
);
});
await waitFor(() => {
expect(sendMessageFn).not.toBeNull();
});
// Send multiple messages
await act(async () => {
await sendMessageFn!({
role: "user",
content: [{ type: "text", text: "Message 1" }],
});
await sendMessageFn!({
role: "user",
content: [{ type: "text", text: "Message 2" }],
});
});
// Assert - both messages should have been received in order
await waitFor(() => {
expect(receivedMessages.length).toBe(2);
});
expect(receivedMessages[0]).toEqual({
role: "user",
content: [{ type: "text", text: "Message 1" }],
});
expect(receivedMessages[1]).toEqual({
role: "user",
content: [{ type: "text", text: "Message 2" }],
});
});
it.todo("should send user actions through WebSocket when connected");
it.todo("should handle send attempts when disconnected");
});
// 8. History Loading State Tests

View File

@@ -0,0 +1,32 @@
import { WebClientConfig } from "#/api/option-service/option.types";
/**
* Creates a mock WebClientConfig with all required fields.
* Use this helper to create test config objects with sensible defaults.
*/
export const createMockWebClientConfig = (
overrides: Partial<WebClientConfig> = {},
): WebClientConfig => ({
app_mode: "oss",
posthog_client_key: "test-posthog-key",
feature_flags: {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: false,
hide_billing_page: false,
hide_integrations_page: false,
...overrides.feature_flags,
},
providers_configured: [],
maintenance_start_time: null,
auth_url: null,
recaptcha_site_key: null,
faulty_models: [],
error_message: null,
updated_at: new Date().toISOString(),
github_app_slug: null,
...overrides,
});

View File

@@ -1,45 +0,0 @@
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useInviteMembersBatch } from "#/hooks/mutation/use-invite-members-batch";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
// Mock the useRevalidator hook from react-router
vi.mock("react-router", () => ({
useRevalidator: () => ({
revalidate: vi.fn(),
}),
}));
describe("useInviteMembersBatch", () => {
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: null });
});
it("should throw an error when organizationId is null", async () => {
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
retry: false,
},
},
});
const { result } = renderHook(() => useInviteMembersBatch(), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
});
// Attempt to mutate without organizationId
result.current.mutate({ emails: ["test@example.com"] });
// Should fail with an error about missing organizationId
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toBeInstanceOf(Error);
expect(result.current.error?.message).toBe("Organization ID is required");
});
});

View File

@@ -1,45 +0,0 @@
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useRemoveMember } from "#/hooks/mutation/use-remove-member";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
// Mock the useRevalidator hook from react-router
vi.mock("react-router", () => ({
useRevalidator: () => ({
revalidate: vi.fn(),
}),
}));
describe("useRemoveMember", () => {
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: null });
});
it("should throw an error when organizationId is null", async () => {
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
retry: false,
},
},
});
const { result } = renderHook(() => useRemoveMember(), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
});
// Attempt to mutate without organizationId
result.current.mutate({ userId: "user-123" });
// Should fail with an error about missing organizationId
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toBeInstanceOf(Error);
expect(result.current.error?.message).toBe("Organization ID is required");
});
});

View File

@@ -1,174 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { organizationService } from "#/api/organization-service/organization-service.api";
import { useOrganizations } from "#/hooks/query/use-organizations";
import type { Organization } from "#/types/org";
vi.mock("#/api/organization-service/organization-service.api", () => ({
organizationService: {
getOrganizations: vi.fn(),
},
}));
// Mock useIsAuthed to return authenticated
vi.mock("#/hooks/query/use-is-authed", () => ({
useIsAuthed: () => ({ data: true }),
}));
// Mock useConfig to return SaaS mode (organizations are a SaaS-only feature)
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => ({ data: { app_mode: "saas" } }),
}));
const mockGetOrganizations = vi.mocked(organizationService.getOrganizations);
function createMinimalOrg(
id: string,
name: string,
is_personal?: boolean,
): Organization {
return {
id,
name,
is_personal,
contact_name: "",
contact_email: "",
conversation_expiration: 0,
agent: "",
default_max_iterations: 0,
security_analyzer: "",
confirmation_mode: false,
default_llm_model: "",
default_llm_api_key_for_byor: "",
default_llm_base_url: "",
remote_runtime_resource_factor: 0,
enable_default_condenser: false,
billing_margin: 0,
enable_proactive_conversation_starters: false,
sandbox_base_container_image: "",
sandbox_runtime_container_image: "",
org_version: 0,
mcp_config: { tools: [], settings: {} },
search_api_key: null,
sandbox_api_key: null,
max_budget_per_task: 0,
enable_solvability_analysis: false,
v1_enabled: false,
credits: 0,
};
}
describe("useOrganizations", () => {
let queryClient: QueryClient;
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
vi.clearAllMocks();
});
it("sorts personal workspace first, then non-personal alphabetically by name", async () => {
// API returns unsorted: Beta, Personal, Acme, All Hands
mockGetOrganizations.mockResolvedValue({
items: [
createMinimalOrg("3", "Beta LLC", false),
createMinimalOrg("1", "Personal Workspace", true),
createMinimalOrg("2", "Acme Corp", false),
createMinimalOrg("4", "All Hands AI", false),
],
currentOrgId: "1",
});
const { result } = renderHook(() => useOrganizations(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
const { organizations } = result.current.data!;
expect(organizations).toHaveLength(4);
expect(organizations[0].id).toBe("1");
expect(organizations[0].is_personal).toBe(true);
expect(organizations[0].name).toBe("Personal Workspace");
expect(organizations[1].name).toBe("Acme Corp");
expect(organizations[2].name).toBe("All Hands AI");
expect(organizations[3].name).toBe("Beta LLC");
});
it("treats missing is_personal as false and sorts by name", async () => {
mockGetOrganizations.mockResolvedValue({
items: [
createMinimalOrg("1", "Zebra Org"), // no is_personal
createMinimalOrg("2", "Alpha Org", true), // personal first
createMinimalOrg("3", "Mango Org"), // no is_personal
],
currentOrgId: "2",
});
const { result } = renderHook(() => useOrganizations(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
const { organizations } = result.current.data!;
expect(organizations[0].id).toBe("2");
expect(organizations[0].is_personal).toBe(true);
expect(organizations[1].name).toBe("Mango Org");
expect(organizations[2].name).toBe("Zebra Org");
});
it("handles missing name by treating as empty string for sort", async () => {
const orgWithName = createMinimalOrg("2", "Beta", false);
const orgNoName = { ...createMinimalOrg("1", "Alpha", false) };
delete (orgNoName as Record<string, unknown>).name;
mockGetOrganizations.mockResolvedValue({
items: [orgWithName, orgNoName] as Organization[],
currentOrgId: "1",
});
const { result } = renderHook(() => useOrganizations(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
const { organizations } = result.current.data!;
// undefined name is coerced to ""; "" sorts before "Beta"
expect(organizations[0].id).toBe("1");
expect(organizations[1].id).toBe("2");
expect(organizations[1].name).toBe("Beta");
});
it("does not mutate the original array from the API", async () => {
const apiOrgs = [
createMinimalOrg("2", "Acme", false),
createMinimalOrg("1", "Personal", true),
];
mockGetOrganizations.mockResolvedValue({
items: apiOrgs,
currentOrgId: "1",
});
const { result } = renderHook(() => useOrganizations(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
// Hook sorts a copy ([...data]), so API order unchanged
expect(apiOrgs[0].id).toBe("2");
expect(apiOrgs[1].id).toBe("1");
// Returned data is sorted
expect(result.current.data!.organizations[0].id).toBe("1");
expect(result.current.data!.organizations[1].id).toBe("2");
});
});

View File

@@ -1,594 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useDraftPersistence } from "#/hooks/chat/use-draft-persistence";
import * as conversationLocalStorage from "#/utils/conversation-local-storage";
// Mock the entire module
vi.mock("#/utils/conversation-local-storage", () => ({
useConversationLocalStorageState: vi.fn(),
getConversationState: vi.fn(),
setConversationState: vi.fn(),
}));
// Mock the getTextContent utility
vi.mock("#/components/features/chat/utils/chat-input.utils", () => ({
getTextContent: vi.fn((el: HTMLDivElement | null) => el?.textContent || ""),
}));
describe("useDraftPersistence", () => {
let mockSetDraftMessage: (message: string | null) => void;
// Create a mock ref to contentEditable div
const createMockChatInputRef = (initialContent = "") => {
const div = document.createElement("div");
div.setAttribute("contenteditable", "true");
div.textContent = initialContent;
return { current: div };
};
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
localStorage.clear();
mockSetDraftMessage = vi.fn<(message: string | null) => void>();
// Default mock for useConversationLocalStorageState
vi.mocked(conversationLocalStorage.useConversationLocalStorageState).mockReturnValue({
state: {
selectedTab: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: null,
},
setSelectedTab: vi.fn(),
setRightPanelShown: vi.fn(),
setUnpinnedTabs: vi.fn(),
setConversationMode: vi.fn(),
setDraftMessage: mockSetDraftMessage,
});
// Default mock for getConversationState
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
selectedTab: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: null,
});
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
describe("draft restoration on mount", () => {
it("restores draft from localStorage when mounting with existing draft", () => {
// Arrange
const conversationId = "conv-restore-1";
const savedDraft = "Previously saved draft message";
const chatInputRef = createMockChatInputRef();
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
selectedTab: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: savedDraft,
});
// Act
renderHook(() => useDraftPersistence(conversationId, chatInputRef));
// Assert - draft should be restored to the DOM element
expect(chatInputRef.current?.textContent).toBe(savedDraft);
});
it("clears input on mount then restores draft if exists", () => {
// Arrange
const conversationId = "conv-restore-2";
const existingContent = "Stale content from previous conversation";
const savedDraft = "Saved draft";
const chatInputRef = createMockChatInputRef(existingContent);
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
selectedTab: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: savedDraft,
});
// Act
renderHook(() => useDraftPersistence(conversationId, chatInputRef));
// Assert - input cleared then draft restored
expect(chatInputRef.current?.textContent).toBe(savedDraft);
});
it("clears input when no draft exists for conversation", () => {
// Arrange
const conversationId = "conv-no-draft";
const chatInputRef = createMockChatInputRef("Some stale content");
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
selectedTab: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: null,
});
// Act
renderHook(() => useDraftPersistence(conversationId, chatInputRef));
// Assert - content should be cleared since there's no draft
expect(chatInputRef.current?.textContent).toBe("");
});
});
describe("debounced saving", () => {
it("saves draft after debounce period", () => {
// Arrange
const conversationId = "conv-debounce-1";
const chatInputRef = createMockChatInputRef();
const { result } = renderHook(() =>
useDraftPersistence(conversationId, chatInputRef),
);
// Act - simulate user typing
chatInputRef.current!.textContent = "New draft content";
act(() => {
result.current.saveDraft();
});
// Assert - should not save immediately
expect(mockSetDraftMessage).not.toHaveBeenCalled();
// Fast forward past debounce period (500ms)
act(() => {
vi.advanceTimersByTime(500);
});
// Assert - should save after debounce
expect(mockSetDraftMessage).toHaveBeenCalledWith("New draft content");
});
it("cancels pending save when new input arrives before debounce", () => {
// Arrange
const conversationId = "conv-debounce-2";
const chatInputRef = createMockChatInputRef();
const { result } = renderHook(() =>
useDraftPersistence(conversationId, chatInputRef),
);
// Act - first input
chatInputRef.current!.textContent = "First";
act(() => {
result.current.saveDraft();
});
// Wait 200ms (less than debounce)
act(() => {
vi.advanceTimersByTime(200);
});
// Second input before debounce completes
chatInputRef.current!.textContent = "First Second";
act(() => {
result.current.saveDraft();
});
// Complete the second debounce
act(() => {
vi.advanceTimersByTime(500);
});
// Assert - should only save the final value once
expect(mockSetDraftMessage).toHaveBeenCalledTimes(1);
expect(mockSetDraftMessage).toHaveBeenCalledWith("First Second");
});
it("does not save if content matches existing draft", () => {
// Arrange
const conversationId = "conv-no-change";
const existingDraft = "Existing draft";
const chatInputRef = createMockChatInputRef(existingDraft);
vi.mocked(conversationLocalStorage.useConversationLocalStorageState).mockReturnValue({
state: {
selectedTab: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: existingDraft,
},
setSelectedTab: vi.fn(),
setRightPanelShown: vi.fn(),
setUnpinnedTabs: vi.fn(),
setConversationMode: vi.fn(),
setDraftMessage: mockSetDraftMessage,
});
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
selectedTab: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: existingDraft,
});
const { result } = renderHook(() =>
useDraftPersistence(conversationId, chatInputRef),
);
// Act - try to save same content
act(() => {
result.current.saveDraft();
});
act(() => {
vi.advanceTimersByTime(500);
});
// Assert - should not save since content is the same
expect(mockSetDraftMessage).not.toHaveBeenCalled();
});
});
describe("clearDraft", () => {
it("clears the draft from localStorage", () => {
// Arrange
const conversationId = "conv-clear-1";
const chatInputRef = createMockChatInputRef("Some content");
const { result } = renderHook(() =>
useDraftPersistence(conversationId, chatInputRef),
);
// Act
act(() => {
result.current.clearDraft();
});
// Assert
expect(mockSetDraftMessage).toHaveBeenCalledWith(null);
});
it("cancels any pending debounced save when clearing", () => {
// Arrange
const conversationId = "conv-clear-2";
const chatInputRef = createMockChatInputRef();
const { result } = renderHook(() =>
useDraftPersistence(conversationId, chatInputRef),
);
// Start a save
chatInputRef.current!.textContent = "Pending draft";
act(() => {
result.current.saveDraft();
});
// Clear before debounce completes
act(() => {
vi.advanceTimersByTime(200);
result.current.clearDraft();
});
// Complete the original debounce period
act(() => {
vi.advanceTimersByTime(500);
});
// Assert - only the clear should have been called (the pending save should be cancelled)
expect(mockSetDraftMessage).toHaveBeenCalledTimes(1);
expect(mockSetDraftMessage).toHaveBeenCalledWith(null);
});
});
describe("conversation switching", () => {
it("clears input when switching to a new conversation without a draft", () => {
// Arrange
const chatInputRef = createMockChatInputRef("Draft from conv A");
// First conversation has a draft
vi.mocked(conversationLocalStorage.getConversationState)
.mockReturnValueOnce({
selectedTab: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: "Draft from conv A",
})
.mockReturnValue({
selectedTab: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: null,
});
const { rerender } = renderHook(
({ conversationId }) =>
useDraftPersistence(conversationId, chatInputRef),
{ initialProps: { conversationId: "conv-A" } },
);
// Act - switch to conversation B
rerender({ conversationId: "conv-B" });
// Assert - input should be cleared (no draft for conv-B)
expect(chatInputRef.current?.textContent).toBe("");
});
it("restores draft when switching to a conversation with an existing draft", () => {
// Arrange
const chatInputRef = createMockChatInputRef();
const draftForConvB = "Saved draft for conversation B";
vi.mocked(conversationLocalStorage.getConversationState)
.mockReturnValueOnce({
selectedTab: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: null,
})
.mockReturnValue({
selectedTab: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: draftForConvB,
});
const { rerender } = renderHook(
({ conversationId }) =>
useDraftPersistence(conversationId, chatInputRef),
{ initialProps: { conversationId: "conv-A" } },
);
// Act - switch to conversation B
rerender({ conversationId: "conv-B" });
// Assert - draft for conv-B should be restored
expect(chatInputRef.current?.textContent).toBe(draftForConvB);
});
it("cancels pending save when switching conversations", () => {
// Arrange
const chatInputRef = createMockChatInputRef();
const { result, rerender } = renderHook(
({ conversationId }) =>
useDraftPersistence(conversationId, chatInputRef),
{ initialProps: { conversationId: "conv-A" } },
);
// Start typing in conv-A
chatInputRef.current!.textContent = "Draft for conv-A";
act(() => {
result.current.saveDraft();
});
// Switch conversation before debounce completes
act(() => {
vi.advanceTimersByTime(200);
});
rerender({ conversationId: "conv-B" });
// Complete the debounce period
act(() => {
vi.advanceTimersByTime(500);
});
// Assert - the save should NOT have happened because conversation changed
expect(mockSetDraftMessage).not.toHaveBeenCalled();
});
});
describe("task ID to real conversation ID transition", () => {
it("transfers draft from task ID to real conversation ID during transition", () => {
// Arrange
const chatInputRef = createMockChatInputRef("Draft typed during init");
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
selectedTab: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: null,
});
const { rerender } = renderHook(
({ conversationId }) =>
useDraftPersistence(conversationId, chatInputRef),
{ initialProps: { conversationId: "task-abc-123" } },
);
// Simulate user typing during task initialization
chatInputRef.current!.textContent = "Draft typed during init";
// Act - transition to real conversation ID
rerender({ conversationId: "conv-real-123" });
// Assert - draft should be saved to the new real conversation ID
expect(conversationLocalStorage.setConversationState).toHaveBeenCalledWith(
"conv-real-123",
{ draftMessage: "Draft typed during init" },
);
// And the draft should remain visible in the input
expect(chatInputRef.current?.textContent).toBe("Draft typed during init");
});
it("does not transfer empty draft during task-to-real transition", () => {
// Arrange
const chatInputRef = createMockChatInputRef("");
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
selectedTab: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: null,
});
const { rerender } = renderHook(
({ conversationId }) =>
useDraftPersistence(conversationId, chatInputRef),
{ initialProps: { conversationId: "task-abc-123" } },
);
// Act - transition to real conversation ID with empty input
rerender({ conversationId: "conv-real-123" });
// Assert - no draft should be saved (input is cleared, checked by hook)
// The setConversationState should not be called with draftMessage
expect(conversationLocalStorage.setConversationState).not.toHaveBeenCalled();
});
it("does not transfer draft for non-task ID transitions", () => {
// Arrange
const chatInputRef = createMockChatInputRef("Some draft");
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
selectedTab: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: null,
});
const { rerender } = renderHook(
({ conversationId }) =>
useDraftPersistence(conversationId, chatInputRef),
{ initialProps: { conversationId: "conv-A" } },
);
// Act - normal conversation switch (not task-to-real)
rerender({ conversationId: "conv-B" });
// Assert - should not use setConversationState directly
// (the normal path uses setDraftMessage from the hook)
expect(conversationLocalStorage.setConversationState).not.toHaveBeenCalled();
});
});
describe("hasDraft and isRestored state", () => {
it("returns hasDraft true when draft exists in hook state", () => {
// Arrange
const conversationId = "conv-has-draft";
const chatInputRef = createMockChatInputRef();
vi.mocked(conversationLocalStorage.useConversationLocalStorageState).mockReturnValue({
state: {
selectedTab: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: "Existing draft",
},
setSelectedTab: vi.fn(),
setRightPanelShown: vi.fn(),
setUnpinnedTabs: vi.fn(),
setConversationMode: vi.fn(),
setDraftMessage: mockSetDraftMessage,
});
// Act
const { result } = renderHook(() =>
useDraftPersistence(conversationId, chatInputRef),
);
// Assert
expect(result.current.hasDraft).toBe(true);
});
it("returns hasDraft false when no draft exists", () => {
// Arrange
const conversationId = "conv-no-draft";
const chatInputRef = createMockChatInputRef();
// Act
const { result } = renderHook(() =>
useDraftPersistence(conversationId, chatInputRef),
);
// Assert
expect(result.current.hasDraft).toBe(false);
});
it("sets isRestored to true after restoration completes", () => {
// Arrange
const conversationId = "conv-restored";
const chatInputRef = createMockChatInputRef();
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
selectedTab: "editor",
rightPanelShown: true,
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: "Draft to restore",
});
// Act
const { result } = renderHook(() =>
useDraftPersistence(conversationId, chatInputRef),
);
// Assert
expect(result.current.isRestored).toBe(true);
});
});
describe("cleanup on unmount", () => {
it("clears pending timeout on unmount", () => {
// Arrange
const conversationId = "conv-unmount";
const chatInputRef = createMockChatInputRef();
const { result, unmount } = renderHook(() =>
useDraftPersistence(conversationId, chatInputRef),
);
// Start a save
chatInputRef.current!.textContent = "Draft";
act(() => {
result.current.saveDraft();
});
// Unmount before debounce completes
unmount();
// Complete the debounce period
act(() => {
vi.advanceTimersByTime(500);
});
// Assert - save should not have been called after unmount
expect(mockSetDraftMessage).not.toHaveBeenCalled();
});
});
});

View File

@@ -88,7 +88,6 @@ describe("useHandlePlanClick", () => {
unpinnedTabs: [],
subConversationTaskId: null,
conversationMode: "code",
draftMessage: null,
});
});
@@ -118,7 +117,6 @@ describe("useHandlePlanClick", () => {
unpinnedTabs: [],
subConversationTaskId: storedTaskId,
conversationMode: "code",
draftMessage: null,
});
renderHook(() => useHandlePlanClick());
@@ -157,7 +155,6 @@ describe("useHandlePlanClick", () => {
unpinnedTabs: [],
subConversationTaskId: storedTaskId,
conversationMode: "code",
draftMessage: null,
});
renderHook(() => useHandlePlanClick());

View File

@@ -1,134 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { useOrgTypeAndAccess } from "#/hooks/use-org-type-and-access";
// Mock the dependencies
vi.mock("#/context/use-selected-organization", () => ({
useSelectedOrganizationId: vi.fn(),
}));
vi.mock("#/hooks/query/use-organizations", () => ({
useOrganizations: vi.fn(),
}));
// Import mocked modules
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
import { useOrganizations } from "#/hooks/query/use-organizations";
const mockUseSelectedOrganizationId = vi.mocked(useSelectedOrganizationId);
const mockUseOrganizations = vi.mocked(useOrganizations);
const queryClient = new QueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
describe("useOrgTypeAndAccess", () => {
beforeEach(() => {
queryClient.clear();
vi.clearAllMocks();
});
it("should return false for all booleans when no organization is selected", async () => {
mockUseSelectedOrganizationId.mockReturnValue({
organizationId: null,
setOrganizationId: vi.fn(),
});
mockUseOrganizations.mockReturnValue({
data: { organizations: [], currentOrgId: null },
} as unknown as ReturnType<typeof useOrganizations>);
const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper });
await waitFor(() => {
expect(result.current.selectedOrg).toBeUndefined();
expect(result.current.isPersonalOrg).toBe(false);
expect(result.current.isTeamOrg).toBe(false);
expect(result.current.canViewOrgRoutes).toBe(false);
expect(result.current.organizationId).toBeNull();
});
});
it("should return isPersonalOrg=true and isTeamOrg=false for personal org", async () => {
const personalOrg = { id: "org-1", is_personal: true, name: "Personal" };
mockUseSelectedOrganizationId.mockReturnValue({
organizationId: "org-1",
setOrganizationId: vi.fn(),
});
mockUseOrganizations.mockReturnValue({
data: { organizations: [personalOrg], currentOrgId: "org-1" },
} as unknown as ReturnType<typeof useOrganizations>);
const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper });
await waitFor(() => {
expect(result.current.selectedOrg).toEqual(personalOrg);
expect(result.current.isPersonalOrg).toBe(true);
expect(result.current.isTeamOrg).toBe(false);
expect(result.current.canViewOrgRoutes).toBe(false);
expect(result.current.organizationId).toBe("org-1");
});
});
it("should return isPersonalOrg=false and isTeamOrg=true for team org", async () => {
const teamOrg = { id: "org-2", is_personal: false, name: "Team" };
mockUseSelectedOrganizationId.mockReturnValue({
organizationId: "org-2",
setOrganizationId: vi.fn(),
});
mockUseOrganizations.mockReturnValue({
data: { organizations: [teamOrg], currentOrgId: "org-2" },
} as unknown as ReturnType<typeof useOrganizations>);
const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper });
await waitFor(() => {
expect(result.current.selectedOrg).toEqual(teamOrg);
expect(result.current.isPersonalOrg).toBe(false);
expect(result.current.isTeamOrg).toBe(true);
expect(result.current.canViewOrgRoutes).toBe(true);
expect(result.current.organizationId).toBe("org-2");
});
});
it("should return canViewOrgRoutes=true only when isTeamOrg AND organizationId is truthy", async () => {
const teamOrg = { id: "org-3", is_personal: false, name: "Team" };
mockUseSelectedOrganizationId.mockReturnValue({
organizationId: "org-3",
setOrganizationId: vi.fn(),
});
mockUseOrganizations.mockReturnValue({
data: { organizations: [teamOrg], currentOrgId: "org-3" },
} as unknown as ReturnType<typeof useOrganizations>);
const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper });
await waitFor(() => {
expect(result.current.isTeamOrg).toBe(true);
expect(result.current.organizationId).toBe("org-3");
expect(result.current.canViewOrgRoutes).toBe(true);
});
});
it("should treat undefined is_personal field as team org", async () => {
// Organization without is_personal field (undefined)
const orgWithoutPersonalField = { id: "org-4", name: "Unknown Type" };
mockUseSelectedOrganizationId.mockReturnValue({
organizationId: "org-4",
setOrganizationId: vi.fn(),
});
mockUseOrganizations.mockReturnValue({
data: { organizations: [orgWithoutPersonalField], currentOrgId: "org-4" },
} as unknown as ReturnType<typeof useOrganizations>);
const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper });
await waitFor(() => {
expect(result.current.selectedOrg).toEqual(orgWithoutPersonalField);
expect(result.current.isPersonalOrg).toBe(false);
expect(result.current.isTeamOrg).toBe(true);
expect(result.current.canViewOrgRoutes).toBe(true);
});
});
});

View File

@@ -1,98 +0,0 @@
import { describe, it, expect } from "vitest";
import { renderHook } from "@testing-library/react";
import { usePermission } from "#/hooks/organizations/use-permissions";
import { rolePermissions } from "#/utils/org/permissions";
import { OrganizationUserRole } from "#/types/org";
describe("usePermission", () => {
const setup = (role: OrganizationUserRole) =>
renderHook(() => usePermission(role)).result.current;
describe("hasPermission", () => {
it("returns true when the role has the permission", () => {
const { hasPermission } = setup("admin");
expect(hasPermission("invite_user_to_organization")).toBe(true);
});
it("returns false when the role does not have the permission", () => {
const { hasPermission } = setup("member");
expect(hasPermission("invite_user_to_organization")).toBe(false);
});
});
describe("rolePermissions integration", () => {
it("matches the permissions defined for the role", () => {
const { hasPermission } = setup("member");
rolePermissions.member.forEach((permission) => {
expect(hasPermission(permission)).toBe(true);
});
});
});
describe("change_user_role permission behavior", () => {
const run = (
activeUserRole: OrganizationUserRole,
targetUserId: string,
targetRole: OrganizationUserRole,
activeUserId = "123",
) => {
const { hasPermission } = renderHook(() =>
usePermission(activeUserRole),
).result.current;
// users can't change their own roles
if (activeUserId === targetUserId) return false;
return hasPermission(`change_user_role:${targetRole}`);
};
describe("member role", () => {
it("cannot change any roles", () => {
expect(run("member", "u2", "member")).toBe(false);
expect(run("member", "u2", "admin")).toBe(false);
expect(run("member", "u2", "owner")).toBe(false);
});
});
describe("admin role", () => {
it("cannot change owner role", () => {
expect(run("admin", "u2", "owner")).toBe(false);
});
it("can change member or admin roles", () => {
expect(run("admin", "u2", "member")).toBe(
rolePermissions.admin.includes("change_user_role:member")
);
expect(run("admin", "u2", "admin")).toBe(
rolePermissions.admin.includes("change_user_role:admin")
);
});
});
describe("owner role", () => {
it("can change owner, admin, and member roles", () => {
expect(run("owner", "u2", "admin")).toBe(
rolePermissions.owner.includes("change_user_role:admin"),
);
expect(run("owner", "u2", "member")).toBe(
rolePermissions.owner.includes("change_user_role:member"),
);
expect(run("owner", "u2", "owner")).toBe(
rolePermissions.owner.includes("change_user_role:owner"),
);
});
});
describe("self role change", () => {
it("is always disallowed", () => {
expect(run("owner", "u2", "member", "u2")).toBe(false);
expect(run("admin", "u2", "member", "u2")).toBe(false);
});
});
});
});

View File

@@ -1,577 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import { useSandboxRecovery } from "#/hooks/use-sandbox-recovery";
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
import * as customToastHandlers from "#/utils/custom-toast-handlers";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => ({
providers: [{ provider: "github", token: "test-token" }],
}),
}));
vi.mock("#/utils/custom-toast-handlers");
vi.mock("#/hooks/mutation/use-unified-start-conversation");
describe("useSandboxRecovery", () => {
let mockMutate: ReturnType<typeof vi.fn>;
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
beforeEach(() => {
vi.clearAllMocks();
mockMutate = vi.fn();
vi.mocked(useUnifiedResumeConversationSandbox).mockReturnValue({
mutate: mockMutate,
mutateAsync: vi.fn(),
isPending: false,
isSuccess: false,
isError: false,
isIdle: true,
data: undefined,
error: null,
reset: vi.fn(),
status: "idle",
variables: undefined,
failureCount: 0,
failureReason: null,
submittedAt: 0,
context: undefined,
} as unknown as ReturnType<typeof useUnifiedResumeConversationSandbox>);
// Reset document.visibilityState
Object.defineProperty(document, "visibilityState", {
value: "visible",
writable: true,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("initial load recovery", () => {
it("should call resumeSandbox on initial load when conversation is STOPPED", () => {
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "STOPPED",
}),
{ wrapper: createWrapper() },
);
expect(mockMutate).toHaveBeenCalledTimes(1);
expect(mockMutate).toHaveBeenCalledWith(
{
conversationId: "conv-123",
providers: [{ provider: "github", token: "test-token" }],
},
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
);
});
it("should NOT call resumeSandbox on initial load when conversation is RUNNING", () => {
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "RUNNING",
}),
{ wrapper: createWrapper() },
);
expect(mockMutate).not.toHaveBeenCalled();
});
it("should NOT call resumeSandbox when conversationId is undefined", () => {
renderHook(
() =>
useSandboxRecovery({
conversationId: undefined,
conversationStatus: "STOPPED",
}),
{ wrapper: createWrapper() },
);
expect(mockMutate).not.toHaveBeenCalled();
});
it("should NOT call resumeSandbox when conversationStatus is undefined", () => {
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: undefined,
}),
{ wrapper: createWrapper() },
);
expect(mockMutate).not.toHaveBeenCalled();
});
it("should only call resumeSandbox once per conversation on initial load", () => {
const { rerender } = renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "STOPPED",
}),
{ wrapper: createWrapper() },
);
expect(mockMutate).toHaveBeenCalledTimes(1);
// Rerender with same props - should not trigger again
rerender();
expect(mockMutate).toHaveBeenCalledTimes(1);
});
it("should call resumeSandbox for a new conversation after navigating", async () => {
const { rerender } = renderHook(
({ conversationId }) =>
useSandboxRecovery({
conversationId,
conversationStatus: "STOPPED",
}),
{
wrapper: createWrapper(),
initialProps: { conversationId: "conv-123" },
},
);
expect(mockMutate).toHaveBeenCalledTimes(1);
expect(mockMutate).toHaveBeenLastCalledWith(
expect.objectContaining({ conversationId: "conv-123" }),
expect.any(Object),
);
// Navigate to a different conversation
rerender({ conversationId: "conv-456" });
await waitFor(() => {
expect(mockMutate).toHaveBeenCalledTimes(2);
});
expect(mockMutate).toHaveBeenLastCalledWith(
expect.objectContaining({ conversationId: "conv-456" }),
expect.any(Object),
);
});
});
describe("tab focus recovery", () => {
it("should call resumeSandbox when tab becomes visible and refetch returns STOPPED", async () => {
// Start with tab hidden
Object.defineProperty(document, "visibilityState", {
value: "hidden",
writable: true,
});
const mockRefetch = vi.fn().mockResolvedValue({
data: { status: "STOPPED" },
});
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "RUNNING", // Cached status is RUNNING
refetchConversation: mockRefetch,
}),
{ wrapper: createWrapper() },
);
// No initial recovery for RUNNING
expect(mockMutate).not.toHaveBeenCalled();
// Simulate tab becoming visible
Object.defineProperty(document, "visibilityState", {
value: "visible",
writable: true,
});
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
});
// Refetch should be called to get fresh status
expect(mockRefetch).toHaveBeenCalledTimes(1);
// Recovery should trigger because fresh status is STOPPED
expect(mockMutate).toHaveBeenCalledTimes(1);
});
it("should NOT call resumeSandbox when tab becomes visible and refetch returns RUNNING", async () => {
const mockRefetch = vi.fn().mockResolvedValue({
data: { status: "RUNNING" },
});
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "RUNNING",
refetchConversation: mockRefetch,
}),
{ wrapper: createWrapper() },
);
// No initial recovery for RUNNING
expect(mockMutate).not.toHaveBeenCalled();
// Simulate tab becoming visible
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
});
// Refetch was called but status is still RUNNING
expect(mockRefetch).toHaveBeenCalledTimes(1);
expect(mockMutate).not.toHaveBeenCalled();
});
it("should NOT call resumeSandbox when tab becomes visible but refetchConversation is not provided", async () => {
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "STOPPED",
// No refetchConversation provided
}),
{ wrapper: createWrapper() },
);
// Initial load triggers recovery
expect(mockMutate).toHaveBeenCalledTimes(1);
mockMutate.mockClear();
// Simulate tab becoming visible
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
});
// No recovery on tab focus without refetchConversation
expect(mockMutate).not.toHaveBeenCalled();
});
it("should NOT call resumeSandbox when tab becomes hidden", async () => {
const mockRefetch = vi.fn().mockResolvedValue({
data: { status: "STOPPED" },
});
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "STOPPED",
refetchConversation: mockRefetch,
}),
{ wrapper: createWrapper() },
);
// Initial load triggers recovery
expect(mockMutate).toHaveBeenCalledTimes(1);
mockMutate.mockClear();
// Simulate tab becoming hidden
Object.defineProperty(document, "visibilityState", {
value: "hidden",
writable: true,
});
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
});
// Refetch should NOT be called when tab is hidden
expect(mockRefetch).not.toHaveBeenCalled();
expect(mockMutate).not.toHaveBeenCalled();
});
it("should clean up visibility event listener on unmount", () => {
const addEventListenerSpy = vi.spyOn(document, "addEventListener");
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
const { unmount } = renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "STOPPED",
}),
{ wrapper: createWrapper() },
);
expect(addEventListenerSpy).toHaveBeenCalledWith(
"visibilitychange",
expect.any(Function),
);
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
"visibilitychange",
expect.any(Function),
);
});
it("should NOT call resumeSandbox when tab becomes visible while isPending is true", async () => {
vi.mocked(useUnifiedResumeConversationSandbox).mockReturnValue({
mutate: mockMutate,
mutateAsync: vi.fn(),
isPending: true,
isSuccess: false,
isError: false,
isIdle: false,
data: undefined,
error: null,
reset: vi.fn(),
status: "pending",
variables: undefined,
failureCount: 0,
failureReason: null,
submittedAt: 0,
context: undefined,
} as unknown as ReturnType<typeof useUnifiedResumeConversationSandbox>);
const mockRefetch = vi.fn().mockResolvedValue({
data: { status: "STOPPED" },
});
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "RUNNING",
refetchConversation: mockRefetch,
}),
{ wrapper: createWrapper() },
);
// Simulate tab becoming visible
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
});
// Refetch will be called when isPending is true
expect(mockRefetch).toHaveBeenCalledTimes(1);
// resumeSandbox should NOT be called
expect(mockMutate).not.toHaveBeenCalled();
});
it("should handle refetch errors gracefully without crashing", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const mockRefetch = vi.fn().mockRejectedValue(new Error("Network error"));
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "RUNNING",
refetchConversation: mockRefetch,
}),
{ wrapper: createWrapper() },
);
// Simulate tab becoming visible
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
});
// Refetch was called
expect(mockRefetch).toHaveBeenCalledTimes(1);
// Error was logged
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Failed to refetch conversation on visibility change:",
expect.any(Error),
);
// No recovery attempt was made (due to error)
expect(mockMutate).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});
describe("recovery callbacks", () => {
it("should return isResuming=false when no recovery is in progress", () => {
const { result } = renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "RUNNING",
}),
{ wrapper: createWrapper() },
);
expect(result.current.isResuming).toBe(false);
});
it("should return isResuming=true when mutation is pending", () => {
vi.mocked(useUnifiedResumeConversationSandbox).mockReturnValue({
mutate: mockMutate,
mutateAsync: vi.fn(),
isPending: true,
isSuccess: false,
isError: false,
isIdle: false,
data: undefined,
error: null,
reset: vi.fn(),
status: "pending",
variables: undefined,
failureCount: 0,
failureReason: null,
submittedAt: 0,
context: undefined,
} as unknown as ReturnType<typeof useUnifiedResumeConversationSandbox>);
const { result } = renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "STOPPED",
}),
{ wrapper: createWrapper() },
);
expect(result.current.isResuming).toBe(true);
});
it("should call onSuccess callback when recovery succeeds", () => {
const onSuccess = vi.fn();
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "STOPPED",
onSuccess,
}),
{ wrapper: createWrapper() },
);
// Get the onSuccess callback passed to mutate
const mutateCall = mockMutate.mock.calls[0];
const options = mutateCall[1];
// Simulate successful mutation
act(() => {
options.onSuccess();
});
expect(onSuccess).toHaveBeenCalledTimes(1);
});
it("should call onError callback and display toast when recovery fails", () => {
const onError = vi.fn();
const testError = new Error("Resume failed");
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "STOPPED",
onError,
}),
{ wrapper: createWrapper() },
);
// Get the onError callback passed to mutate
const mutateCall = mockMutate.mock.calls[0];
const options = mutateCall[1];
// Simulate failed mutation
act(() => {
options.onError(testError);
});
expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(testError);
expect(vi.mocked(customToastHandlers.displayErrorToast)).toHaveBeenCalled();
});
it("should NOT call resumeSandbox when isPending is true", () => {
vi.mocked(useUnifiedResumeConversationSandbox).mockReturnValue({
mutate: mockMutate,
mutateAsync: vi.fn(),
isPending: true,
isSuccess: false,
isError: false,
isIdle: false,
data: undefined,
error: null,
reset: vi.fn(),
status: "pending",
variables: undefined,
failureCount: 0,
failureReason: null,
submittedAt: 0,
context: undefined,
} as unknown as ReturnType<typeof useUnifiedResumeConversationSandbox>);
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "STOPPED",
}),
{ wrapper: createWrapper() },
);
// Should not call mutate because isPending is true
expect(mockMutate).not.toHaveBeenCalled();
});
});
describe("WebSocket disconnect (negative test)", () => {
it("should NOT have any mechanism to auto-resume on WebSocket disconnect", () => {
// This test documents the intended behavior: the hook does NOT
// listen for WebSocket disconnects. Recovery only happens on:
// 1. Initial page load (STOPPED status)
// 2. Tab focus (visibilitychange event)
//
// There is intentionally NO onDisconnect handler or WebSocket listener.
const { result } = renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "RUNNING",
}),
{ wrapper: createWrapper() },
);
// The hook should only expose isResuming - no disconnect-related functionality
expect(result.current).toEqual({
isResuming: expect.any(Boolean),
});
// No calls should have been made for RUNNING status
expect(mockMutate).not.toHaveBeenCalled();
});
});
});

View File

@@ -6,54 +6,18 @@ import OptionService from "#/api/option-service/option-service.api";
import { useSettingsNavItems } from "#/hooks/use-settings-nav-items";
import { WebClientFeatureFlags } from "#/api/option-service/option.types";
// Mock useOrgTypeAndAccess
const mockOrgTypeAndAccess = vi.hoisted(() => ({
isPersonalOrg: false,
isTeamOrg: false,
organizationId: null as string | null,
selectedOrg: null,
canViewOrgRoutes: false,
}));
vi.mock("#/hooks/use-org-type-and-access", () => ({
useOrgTypeAndAccess: () => mockOrgTypeAndAccess,
}));
// Mock useMe
const mockMe = vi.hoisted(() => ({
data: null as { role: string } | null | undefined,
}));
vi.mock("#/hooks/query/use-me", () => ({
useMe: () => mockMe,
}));
const queryClient = new QueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const mockConfig = (
appMode: "saas" | "oss",
hideLlmSettings = false,
enableBilling = true,
) => {
const mockConfig = (appMode: "saas" | "oss", hideLlmSettings = false) => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
app_mode: appMode,
feature_flags: {
hide_llm_settings: hideLlmSettings,
enable_billing: enableBilling,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
},
feature_flags: { hide_llm_settings: hideLlmSettings },
} as Awaited<ReturnType<typeof OptionService.getConfig>>);
};
vi.mock("react-router", () => ({
useRevalidator: () => ({ revalidate: vi.fn() }),
}));
const mockConfigWithFeatureFlags = (
appMode: "saas" | "oss",
featureFlags: Partial<WebClientFeatureFlags>,
@@ -61,7 +25,7 @@ const mockConfigWithFeatureFlags = (
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
app_mode: appMode,
feature_flags: {
enable_billing: true, // Enable billing by default so it's not hidden
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
@@ -77,38 +41,19 @@ const mockConfigWithFeatureFlags = (
describe("useSettingsNavItems", () => {
beforeEach(() => {
queryClient.clear();
vi.restoreAllMocks();
// Reset mock state
mockOrgTypeAndAccess.isPersonalOrg = false;
mockOrgTypeAndAccess.isTeamOrg = false;
mockOrgTypeAndAccess.organizationId = null;
mockOrgTypeAndAccess.selectedOrg = null;
mockOrgTypeAndAccess.canViewOrgRoutes = false;
mockMe.data = null;
});
it("should return SAAS_NAV_ITEMS minus billing/org/org-members when userRole is 'member'", async () => {
it("should return SAAS_NAV_ITEMS when app_mode is 'saas'", async () => {
mockConfig("saas");
mockMe.data = { role: "member" };
mockOrgTypeAndAccess.organizationId = "org-1";
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
await waitFor(() => {
expect(result.current).toEqual(
SAAS_NAV_ITEMS.filter(
(item) =>
item.to !== "/settings/billing" &&
item.to !== "/settings/org" &&
item.to !== "/settings/org-members",
),
);
expect(result.current).toEqual(SAAS_NAV_ITEMS);
});
});
it("should return OSS_NAV_ITEMS when app_mode is 'oss'", async () => {
mockConfig("oss");
mockMe.data = { role: "admin" };
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
await waitFor(() => {
@@ -118,8 +63,6 @@ describe("useSettingsNavItems", () => {
it("should filter out '/settings' item when hide_llm_settings feature flag is enabled", async () => {
mockConfig("saas", true);
mockMe.data = { role: "admin" };
mockOrgTypeAndAccess.organizationId = "org-1";
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
await waitFor(() => {
@@ -129,163 +72,7 @@ describe("useSettingsNavItems", () => {
});
});
describe("org-type and role-based filtering", () => {
it("should include org routes by default for team org admin", async () => {
mockConfig("saas");
mockOrgTypeAndAccess.isTeamOrg = true;
mockOrgTypeAndAccess.organizationId = "org-123";
mockMe.data = { role: "admin" };
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
// Wait for config to load (check that any SAAS item is present)
await waitFor(() => {
expect(result.current.length).toBeGreaterThan(0);
expect(
result.current.find((item) => item.to === "/settings/user"),
).toBeDefined();
});
// Org routes should be included for team org admin
expect(
result.current.find((item) => item.to === "/settings/org"),
).toBeDefined();
expect(
result.current.find((item) => item.to === "/settings/org-members"),
).toBeDefined();
});
it("should hide org routes when isPersonalOrg is true", async () => {
mockConfig("saas");
mockOrgTypeAndAccess.isPersonalOrg = true;
mockOrgTypeAndAccess.organizationId = "org-123";
mockMe.data = { role: "admin" };
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
// Wait for config to load (check that any SAAS item is present)
await waitFor(() => {
expect(result.current.length).toBeGreaterThan(0);
expect(
result.current.find((item) => item.to === "/settings/user"),
).toBeDefined();
});
// Org routes should be filtered out for personal orgs
expect(
result.current.find((item) => item.to === "/settings/org"),
).toBeUndefined();
expect(
result.current.find((item) => item.to === "/settings/org-members"),
).toBeUndefined();
});
it("should hide org routes when user role is member", async () => {
mockConfig("saas");
mockOrgTypeAndAccess.isTeamOrg = true;
mockOrgTypeAndAccess.organizationId = "org-123";
mockMe.data = { role: "member" };
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
// Wait for config to load
await waitFor(() => {
expect(result.current.length).toBeGreaterThan(0);
expect(
result.current.find((item) => item.to === "/settings/user"),
).toBeDefined();
});
// Org routes should be hidden for members
expect(
result.current.find((item) => item.to === "/settings/org"),
).toBeUndefined();
expect(
result.current.find((item) => item.to === "/settings/org-members"),
).toBeUndefined();
});
it("should hide org routes when no organization is selected", async () => {
mockConfig("saas");
mockOrgTypeAndAccess.isTeamOrg = false;
mockOrgTypeAndAccess.isPersonalOrg = false;
mockOrgTypeAndAccess.organizationId = null;
mockMe.data = { role: "admin" };
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
// Wait for config to load
await waitFor(() => {
expect(result.current.length).toBeGreaterThan(0);
expect(
result.current.find((item) => item.to === "/settings/user"),
).toBeDefined();
});
// Org routes should be hidden when no org is selected
expect(
result.current.find((item) => item.to === "/settings/org"),
).toBeUndefined();
expect(
result.current.find((item) => item.to === "/settings/org-members"),
).toBeUndefined();
});
it("should hide billing route when isTeamOrg is true", async () => {
mockConfig("saas");
mockOrgTypeAndAccess.isTeamOrg = true;
mockOrgTypeAndAccess.organizationId = "org-123";
mockMe.data = { role: "admin" };
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
// Wait for config to load
await waitFor(() => {
expect(result.current.length).toBeGreaterThan(0);
expect(
result.current.find((item) => item.to === "/settings/user"),
).toBeDefined();
});
// Billing should be hidden for team orgs
expect(
result.current.find((item) => item.to === "/settings/billing"),
).toBeUndefined();
});
it("should show billing route for personal org", async () => {
mockConfig("saas");
mockOrgTypeAndAccess.isPersonalOrg = true;
mockOrgTypeAndAccess.isTeamOrg = false;
mockOrgTypeAndAccess.organizationId = "org-123";
mockMe.data = { role: "admin" };
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
// Wait for config to load
await waitFor(() => {
expect(result.current.length).toBeGreaterThan(0);
expect(
result.current.find((item) => item.to === "/settings/user"),
).toBeDefined();
});
// Billing should be visible for personal orgs
expect(
result.current.find((item) => item.to === "/settings/billing"),
).toBeDefined();
});
});
describe("hide page feature flags", () => {
beforeEach(() => {
// Set up user as admin with org context so billing is accessible
mockMe.data = { role: "admin" };
mockOrgTypeAndAccess.isPersonalOrg = true; // Personal org shows billing
mockOrgTypeAndAccess.isTeamOrg = false;
mockOrgTypeAndAccess.organizationId = "org-1";
});
it("should filter out '/settings/user' when hide_users_page is true", async () => {
mockConfigWithFeatureFlags("saas", { hide_users_page: true });
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });

View File

@@ -1,286 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useVisibilityChange } from "#/hooks/use-visibility-change";
describe("useVisibilityChange", () => {
beforeEach(() => {
// Reset document.visibilityState to visible
Object.defineProperty(document, "visibilityState", {
value: "visible",
writable: true,
configurable: true,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("initial state", () => {
it("should return isVisible=true when document is visible", () => {
Object.defineProperty(document, "visibilityState", {
value: "visible",
writable: true,
});
const { result } = renderHook(() => useVisibilityChange());
expect(result.current.isVisible).toBe(true);
});
it("should return isVisible=false when document is hidden", () => {
Object.defineProperty(document, "visibilityState", {
value: "hidden",
writable: true,
});
const { result } = renderHook(() => useVisibilityChange());
expect(result.current.isVisible).toBe(false);
});
});
describe("visibility change events", () => {
it("should update isVisible when visibility changes to hidden", () => {
const { result } = renderHook(() => useVisibilityChange());
expect(result.current.isVisible).toBe(true);
// Simulate tab becoming hidden
Object.defineProperty(document, "visibilityState", {
value: "hidden",
writable: true,
});
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(result.current.isVisible).toBe(false);
});
it("should update isVisible when visibility changes to visible", () => {
Object.defineProperty(document, "visibilityState", {
value: "hidden",
writable: true,
});
const { result } = renderHook(() => useVisibilityChange());
expect(result.current.isVisible).toBe(false);
// Simulate tab becoming visible
Object.defineProperty(document, "visibilityState", {
value: "visible",
writable: true,
});
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(result.current.isVisible).toBe(true);
});
});
describe("callbacks", () => {
it("should call onVisibilityChange with the new state", () => {
const onVisibilityChange = vi.fn();
renderHook(() => useVisibilityChange({ onVisibilityChange }));
// Simulate tab becoming hidden
Object.defineProperty(document, "visibilityState", {
value: "hidden",
writable: true,
});
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(onVisibilityChange).toHaveBeenCalledWith("hidden");
// Simulate tab becoming visible
Object.defineProperty(document, "visibilityState", {
value: "visible",
writable: true,
});
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(onVisibilityChange).toHaveBeenCalledWith("visible");
});
it("should call onVisible only when tab becomes visible", () => {
const onVisible = vi.fn();
const onHidden = vi.fn();
renderHook(() => useVisibilityChange({ onVisible, onHidden }));
// Simulate tab becoming hidden
Object.defineProperty(document, "visibilityState", {
value: "hidden",
writable: true,
});
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(onVisible).not.toHaveBeenCalled();
expect(onHidden).toHaveBeenCalledTimes(1);
// Simulate tab becoming visible
Object.defineProperty(document, "visibilityState", {
value: "visible",
writable: true,
});
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(onVisible).toHaveBeenCalledTimes(1);
expect(onHidden).toHaveBeenCalledTimes(1);
});
it("should call onHidden only when tab becomes hidden", () => {
const onHidden = vi.fn();
renderHook(() => useVisibilityChange({ onHidden }));
// Simulate tab becoming hidden
Object.defineProperty(document, "visibilityState", {
value: "hidden",
writable: true,
});
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(onHidden).toHaveBeenCalledTimes(1);
// Simulate tab becoming visible (should not call onHidden)
Object.defineProperty(document, "visibilityState", {
value: "visible",
writable: true,
});
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(onHidden).toHaveBeenCalledTimes(1);
});
});
describe("enabled option", () => {
it("should not listen for events when enabled=false", () => {
const onVisible = vi.fn();
renderHook(() => useVisibilityChange({ onVisible, enabled: false }));
// Simulate tab becoming visible
Object.defineProperty(document, "visibilityState", {
value: "visible",
writable: true,
});
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(onVisible).not.toHaveBeenCalled();
});
it("should start listening when enabled changes from false to true", () => {
const onVisible = vi.fn();
const { rerender } = renderHook(
({ enabled }) => useVisibilityChange({ onVisible, enabled }),
{ initialProps: { enabled: false } },
);
// Simulate event while disabled
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(onVisible).not.toHaveBeenCalled();
// Enable the hook
rerender({ enabled: true });
// Now events should be captured
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(onVisible).toHaveBeenCalledTimes(1);
});
});
describe("cleanup", () => {
it("should remove event listener on unmount", () => {
const addEventListenerSpy = vi.spyOn(document, "addEventListener");
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
const { unmount } = renderHook(() => useVisibilityChange());
expect(addEventListenerSpy).toHaveBeenCalledWith(
"visibilitychange",
expect.any(Function),
);
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
"visibilitychange",
expect.any(Function),
);
});
it("should remove event listener when enabled changes to false", () => {
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
const { rerender } = renderHook(
({ enabled }) => useVisibilityChange({ enabled }),
{ initialProps: { enabled: true } },
);
rerender({ enabled: false });
expect(removeEventListenerSpy).toHaveBeenCalledWith(
"visibilitychange",
expect.any(Function),
);
});
});
describe("callback stability", () => {
it("should handle callback updates without missing events", () => {
const onVisible1 = vi.fn();
const onVisible2 = vi.fn();
const { rerender } = renderHook(
({ onVisible }) => useVisibilityChange({ onVisible }),
{ initialProps: { onVisible: onVisible1 } },
);
// Update callback
rerender({ onVisible: onVisible2 });
// Simulate visibility change
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(onVisible1).not.toHaveBeenCalled();
expect(onVisible2).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,79 @@
import { screen } from "@testing-library/react";
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>,
);
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
});
it("should not attempt to load unsupported language codes", async () => {
// Test that the configuration prevents 404 errors by not attempting to load
// unsupported language codes like 'en-US@posix'
const originalLanguage = i18n.language;
try {
// With nonExplicitSupportedLngs: false, i18next will not attempt to load
// unsupported language codes, preventing 404 errors
// Test with a language code that includes region but is not in supportedLngs
await i18n.changeLanguage("en-US@posix");
// Since "en-US@posix" is not in supportedLngs and nonExplicitSupportedLngs is false,
// i18next should fall back to the fallbackLng ("en")
expect(i18n.language).toBe("en");
// Test another unsupported region code
await i18n.changeLanguage("ja-JP");
// Even with nonExplicitSupportedLngs: false, i18next still falls back to base language
// if it exists in supportedLngs, but importantly, it won't make a 404 request first
expect(i18n.language).toBe("ja");
// Test that supported languages still work
await i18n.changeLanguage("ja");
expect(i18n.language).toBe("ja");
await i18n.changeLanguage("zh-CN");
expect(i18n.language).toBe("zh-CN");
} finally {
// Restore the original language
await i18n.changeLanguage(originalLanguage);
}
});
it("should have proper i18n configuration", () => {
// Test that the i18n instance has the expected configuration
expect(i18n.options.supportedLngs).toBeDefined();
// nonExplicitSupportedLngs should be false to prevent 404 errors
expect(i18n.options.nonExplicitSupportedLngs).toBe(false);
// fallbackLng can be a string or array, check if it includes "en"
const fallbackLng = i18n.options.fallbackLng;
if (Array.isArray(fallbackLng)) {
expect(fallbackLng).toContain("en");
} else {
expect(fallbackLng).toBe("en");
}
// Test that supported languages include both base and region-specific codes
const supportedLngs = i18n.options.supportedLngs as string[];
expect(supportedLngs).toContain("en");
expect(supportedLngs).toContain("zh-CN");
expect(supportedLngs).toContain("zh-TW");
expect(supportedLngs).toContain("ko-KR");
});
});

View File

@@ -1,10 +0,0 @@
import { describe, expect, it } from "vitest";
import { clientLoader } from "#/routes/api-keys";
describe("clientLoader permission checks", () => {
it("should export a clientLoader for route protection", () => {
// This test verifies the clientLoader is exported (for consistency with other routes)
expect(clientLoader).toBeDefined();
expect(typeof clientLoader).toBe("function");
});
});

View File

@@ -2,7 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import AppSettingsScreen, { clientLoader } from "#/routes/app-settings";
import AppSettingsScreen from "#/routes/app-settings";
import SettingsService from "#/api/settings-service/settings-service.api";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { AvailableLanguages } from "#/i18n";
@@ -18,14 +18,6 @@ const renderAppSettingsScreen = () =>
),
});
describe("clientLoader permission checks", () => {
it("should export a clientLoader for route protection", () => {
// This test verifies the clientLoader is exported (for consistency with other routes)
expect(clientLoader).toBeDefined();
expect(typeof clientLoader).toBe("function");
});
});
describe("Content", () => {
it("should render the screen", () => {
renderAppSettingsScreen();

View File

@@ -1,367 +0,0 @@
import { render, screen, waitFor } from "@testing-library/react";
import { createRoutesStub } from "react-router";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { QueryClientProvider } from "@tanstack/react-query";
import BillingSettingsScreen, { clientLoader } from "#/routes/billing";
import { PaymentForm } from "#/components/features/payment/payment-form";
import OptionService from "#/api/option-service/option-service.api";
import { OrganizationMember } from "#/types/org";
import * as orgStore from "#/stores/selected-organization-store";
import { organizationService } from "#/api/organization-service/organization-service.api";
import { createMockWebClientConfig } from "#/mocks/settings-handlers";
// Mock the i18next hook
vi.mock("react-i18next", async () => {
const actual =
await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => key,
i18n: {
changeLanguage: vi.fn(),
},
}),
};
});
// Mock useTracking hook
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackCreditsPurchased: vi.fn(),
}),
}));
// Mock useBalance hook
const mockUseBalance = vi.fn();
vi.mock("#/hooks/query/use-balance", () => ({
useBalance: () => mockUseBalance(),
}));
// Mock useCreateStripeCheckoutSession hook
vi.mock(
"#/hooks/mutation/stripe/use-create-stripe-checkout-session",
() => ({
useCreateStripeCheckoutSession: () => ({
mutate: vi.fn(),
isPending: false,
}),
}),
);
describe("Billing Route", () => {
const { mockQueryClient } = vi.hoisted(() => ({
mockQueryClient: (() => {
const { QueryClient } = require("@tanstack/react-query");
return new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
})(),
}));
// Mock queryClient to use our test instance
vi.mock("#/query-client-config", () => ({
queryClient: mockQueryClient,
}));
const createMockUser = (
overrides: Partial<OrganizationMember> = {},
): OrganizationMember => ({
org_id: "org-1",
user_id: "user-1",
email: "test@example.com",
role: "member",
llm_api_key: "",
max_iterations: 100,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "",
status: "active",
...overrides,
});
const seedActiveUser = (user: Partial<OrganizationMember>) => {
orgStore.useSelectedOrganizationStore.setState({ organizationId: "org-1" });
vi.spyOn(organizationService, "getMe").mockResolvedValue(
createMockUser(user),
);
};
const setupSaasMode = (featureFlags = {}) => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({
app_mode: "saas",
feature_flags: {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: false,
hide_billing_page: false,
hide_integrations_page: false,
...featureFlags,
},
}),
);
};
beforeEach(() => {
mockQueryClient.clear();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("clientLoader cache key", () => {
it("should use the 'web-client-config' query key to read cached config", async () => {
// Arrange: pre-populate the cache under the canonical key
seedActiveUser({ role: "admin" });
const cachedConfig = {
app_mode: "saas" as const,
posthog_client_key: "test",
feature_flags: {
enable_billing: true,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
},
};
mockQueryClient.setQueryData(["web-client-config"], cachedConfig);
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// Act: invoke the clientLoader directly
const result = await clientLoader();
// Assert: the loader should have found the cached config and NOT called getConfig
expect(getConfigSpy).not.toHaveBeenCalled();
expect(result).toBeNull(); // admin with billing enabled = no redirect
});
});
describe("clientLoader permission checks", () => {
it("should redirect members to /settings/user when accessing billing directly", async () => {
// Arrange
setupSaasMode();
seedActiveUser({ role: "member" });
const RouterStub = createRoutesStub([
{
Component: BillingSettingsScreen,
loader: clientLoader,
path: "/settings/billing",
},
{
Component: () => <div data-testid="user-settings-screen" />,
path: "/settings/user",
},
]);
// Act
render(<RouterStub initialEntries={["/settings/billing"]} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={mockQueryClient}>
{children}
</QueryClientProvider>
),
});
// Assert - should be redirected to user settings
await waitFor(() => {
expect(screen.getByTestId("user-settings-screen")).toBeInTheDocument();
});
});
it("should allow admins to access billing route", async () => {
// Arrange
setupSaasMode();
seedActiveUser({ role: "admin" });
const RouterStub = createRoutesStub([
{
Component: BillingSettingsScreen,
loader: clientLoader,
path: "/settings/billing",
},
{
Component: () => <div data-testid="user-settings-screen" />,
path: "/settings/user",
},
]);
// Act
render(<RouterStub initialEntries={["/settings/billing"]} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={mockQueryClient}>
{children}
</QueryClientProvider>
),
});
// Assert - should stay on billing page (component renders PaymentForm)
await waitFor(() => {
expect(
screen.queryByTestId("user-settings-screen"),
).not.toBeInTheDocument();
});
});
it("should allow owners to access billing route", async () => {
// Arrange
setupSaasMode();
seedActiveUser({ role: "owner" });
const RouterStub = createRoutesStub([
{
Component: BillingSettingsScreen,
loader: clientLoader,
path: "/settings/billing",
},
{
Component: () => <div data-testid="user-settings-screen" />,
path: "/settings/user",
},
]);
// Act
render(<RouterStub initialEntries={["/settings/billing"]} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={mockQueryClient}>
{children}
</QueryClientProvider>
),
});
// Assert - should stay on billing page
await waitFor(() => {
expect(
screen.queryByTestId("user-settings-screen"),
).not.toBeInTheDocument();
});
});
it("should redirect when user is undefined (no org selected)", async () => {
// Arrange: no org selected, so getActiveOrganizationUser returns undefined
setupSaasMode();
// Explicitly clear org store so getActiveOrganizationUser returns undefined
orgStore.useSelectedOrganizationStore.setState({ organizationId: null });
const RouterStub = createRoutesStub([
{
Component: BillingSettingsScreen,
loader: clientLoader,
path: "/settings/billing",
},
{
Component: () => <div data-testid="user-settings-screen" />,
path: "/settings/user",
},
]);
// Act
render(<RouterStub initialEntries={["/settings/billing"]} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={mockQueryClient}>
{children}
</QueryClientProvider>
),
});
// Assert - should be redirected to user settings
await waitFor(() => {
expect(screen.getByTestId("user-settings-screen")).toBeInTheDocument();
});
});
it("should redirect all users when enable_billing is false", async () => {
// Arrange: enable_billing=false means billing is hidden for everyone
setupSaasMode({ enable_billing: false });
seedActiveUser({ role: "owner" }); // Even owners should be redirected
const RouterStub = createRoutesStub([
{
Component: BillingSettingsScreen,
loader: clientLoader,
path: "/settings/billing",
},
{
Component: () => <div data-testid="user-settings-screen" />,
path: "/settings/user",
},
]);
// Act
render(<RouterStub initialEntries={["/settings/billing"]} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={mockQueryClient}>
{children}
</QueryClientProvider>
),
});
// Assert - should be redirected to user settings
await waitFor(() => {
expect(screen.getByTestId("user-settings-screen")).toBeInTheDocument();
});
});
});
describe("PaymentForm permission behavior", () => {
beforeEach(() => {
mockUseBalance.mockReturnValue({
data: "150.00",
isLoading: false,
});
});
it("should disable input and button when isDisabled is true, but show balance", async () => {
// Arrange & Act
render(<PaymentForm isDisabled />, {
wrapper: ({ children }) => (
<QueryClientProvider client={mockQueryClient}>
{children}
</QueryClientProvider>
),
});
// Assert - balance is visible
const balance = screen.getByTestId("user-balance");
expect(balance).toBeInTheDocument();
expect(balance).toHaveTextContent("$150.00");
// Assert - input is disabled
const topUpInput = screen.getByTestId("top-up-input");
expect(topUpInput).toBeDisabled();
// Assert - button is disabled
const submitButton = screen.getByRole("button");
expect(submitButton).toBeDisabled();
});
it("should enable input and button when isDisabled is false", async () => {
// Arrange & Act
render(<PaymentForm isDisabled={false} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={mockQueryClient}>
{children}
</QueryClientProvider>
),
});
// Assert - input is enabled
const topUpInput = screen.getByTestId("top-up-input");
expect(topUpInput).not.toBeDisabled();
// Assert - button starts disabled (no amount entered) but is NOT
// permanently disabled by the isDisabled prop
const submitButton = screen.getByRole("button");
// The button is disabled because no valid amount is entered, not because of isDisabled
expect(submitButton).toBeDisabled();
});
});
});

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