Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 877a857e55 | |||
| 2a52902db8 | |||
| 02a5b7c307 | |||
| 4d4645068c | |||
| 8e30ecca11 | |||
| 9722d23f38 | |||
| 587b4c311a | |||
| 7a86402c9c | |||
| 06d283dfa0 | |||
| a6a4246e30 | |||
| 4830b9a67d | |||
| d4489d62d7 | |||
| e41c020073 | |||
| 3f44c8436f | |||
| b740944075 | |||
| 5618a3eebb | |||
| a1ffe5c936 | |||
| f8376a9702 | |||
| 985a634d60 | |||
| e40681ca61 | |||
| 228e50df9c | |||
| fd805eb835 | |||
| 426350224b | |||
| 9b78a5e200 | |||
| 1ce3723b60 | |||
| 95a32ae459 | |||
| 9be0acea9c | |||
| 1a5965b951 | |||
| f5cbb26770 | |||
| 8caad14eb8 | |||
| 3e36911038 | |||
| 43e6ce631a | |||
| 4c3ba62665 | |||
| f5e7c602dc |
@@ -0,0 +1,223 @@
|
||||
name: End-to-End Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
e2e-tests:
|
||||
if: contains(github.event.pull_request.labels.*.name, 'end-to-end') || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
env:
|
||||
GITHUB_REPO_NAME: ${{ github.repository }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install poetry via pipx
|
||||
uses: abatilo/actions-poetry@v3
|
||||
with:
|
||||
poetry-version: 2.1.3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'poetry'
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 xauth xvfb libgbm1 libasound2t64 netcat-openbsd
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'frontend/package-lock.json'
|
||||
|
||||
- name: Setup environment for end-to-end tests
|
||||
run: |
|
||||
# Create test results directory
|
||||
mkdir -p test-results
|
||||
|
||||
# Create downloads directory for OpenHands (use a directory in the home folder)
|
||||
mkdir -p $HOME/downloads
|
||||
sudo chown -R $USER:$USER $HOME/downloads
|
||||
sudo chmod -R 755 $HOME/downloads
|
||||
|
||||
- name: Build OpenHands
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || 'gpt-4o' }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY || 'test-key' }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
INSTALL_DOCKER: 1
|
||||
RUNTIME: docker
|
||||
FRONTEND_PORT: 12000
|
||||
FRONTEND_HOST: 0.0.0.0
|
||||
BACKEND_HOST: 0.0.0.0
|
||||
BACKEND_PORT: 3000
|
||||
ENABLE_BROWSER: true
|
||||
INSTALL_PLAYWRIGHT: 1
|
||||
run: |
|
||||
# Fix poetry.lock file if needed
|
||||
echo "Fixing poetry.lock file if needed..."
|
||||
poetry lock
|
||||
|
||||
# Build OpenHands using make build
|
||||
echo "Running make build..."
|
||||
make build
|
||||
|
||||
# Install Chromium Headless Shell for Playwright (needed for pytest-playwright)
|
||||
echo "Installing Chromium Headless Shell for Playwright..."
|
||||
poetry run playwright install chromium-headless-shell
|
||||
|
||||
# Verify Playwright browsers are installed (for e2e tests only)
|
||||
echo "Verifying Playwright browsers installation for e2e tests..."
|
||||
BROWSER_CHECK=$(poetry run python tests/e2e/check_playwright.py 2>/dev/null)
|
||||
|
||||
if [ "$BROWSER_CHECK" != "chromium_found" ]; then
|
||||
echo "ERROR: Chromium browser not found or not working for e2e tests"
|
||||
echo "$BROWSER_CHECK"
|
||||
exit 1
|
||||
else
|
||||
echo "Playwright browsers are properly installed for e2e tests."
|
||||
fi
|
||||
|
||||
# Docker runtime will handle workspace directory creation
|
||||
|
||||
# Start the application using make run with custom parameters and reduced logging
|
||||
echo "Starting OpenHands using make run..."
|
||||
# Set environment variables to reduce logging verbosity
|
||||
export PYTHONUNBUFFERED=1
|
||||
export LOG_LEVEL=WARNING
|
||||
export UVICORN_LOG_LEVEL=warning
|
||||
export OPENHANDS_LOG_LEVEL=WARNING
|
||||
FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 make run > /tmp/openhands-e2e-test.log 2>&1 &
|
||||
|
||||
# Store the PID of the make run process
|
||||
MAKE_PID=$!
|
||||
echo "OpenHands started with PID: $MAKE_PID"
|
||||
|
||||
# Wait for the application to start
|
||||
echo "Waiting for OpenHands to start..."
|
||||
max_attempts=15
|
||||
attempt=1
|
||||
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
echo "Checking if OpenHands is running (attempt $attempt of $max_attempts)..."
|
||||
|
||||
# Check if the process is still running
|
||||
if ! ps -p $MAKE_PID > /dev/null; then
|
||||
echo "ERROR: OpenHands process has terminated unexpectedly"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if frontend port is open
|
||||
if nc -z localhost 12000; then
|
||||
# Verify we can get HTML content
|
||||
if curl -s http://localhost:12000 | grep -q "<html"; then
|
||||
echo "SUCCESS: OpenHands is running and serving HTML content on port 12000"
|
||||
break
|
||||
else
|
||||
echo "Port 12000 is open but not serving HTML content yet"
|
||||
fi
|
||||
else
|
||||
echo "Frontend port 12000 is not open yet"
|
||||
fi
|
||||
|
||||
# Show log output on each attempt
|
||||
echo "Recent log output:"
|
||||
tail -n 20 /tmp/openhands-e2e-test.log
|
||||
|
||||
# Wait before next attempt
|
||||
echo "Waiting 10 seconds before next check..."
|
||||
sleep 10
|
||||
attempt=$((attempt + 1))
|
||||
|
||||
# Exit if we've reached the maximum number of attempts
|
||||
if [ $attempt -gt $max_attempts ]; then
|
||||
echo "ERROR: OpenHands failed to start after $max_attempts attempts"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Final verification that the app is running
|
||||
if ! nc -z localhost 12000 || ! curl -s http://localhost:12000 | grep -q "<html"; then
|
||||
echo "ERROR: OpenHands is not running properly on port 12000"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Print success message
|
||||
echo "OpenHands is running successfully on port 12000"
|
||||
|
||||
- name: Run end-to-end tests
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.E2E_TEST_GITHUB_TOKEN }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || 'gpt-4o' }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY || 'test-key' }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
run: |
|
||||
# Check if the application is running
|
||||
if ! nc -z localhost 12000; then
|
||||
echo "ERROR: OpenHands is not running on port 12000"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run the tests with detailed output
|
||||
cd tests/e2e
|
||||
poetry run python -m pytest test_settings.py::test_github_token_configuration test_conversation.py::test_conversation_start -v --no-header --capture=no --timeout=600
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: tests/e2e/test-results/
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload OpenHands logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: openhands-logs
|
||||
path: |
|
||||
/tmp/openhands-e2e-test.log
|
||||
/tmp/openhands-e2e-build.log
|
||||
/tmp/openhands-backend.log
|
||||
/tmp/openhands-frontend.log
|
||||
/tmp/backend-health-check.log
|
||||
/tmp/frontend-check.log
|
||||
/tmp/vite-config.log
|
||||
/tmp/makefile-contents.log
|
||||
retention-days: 30
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
# Stop OpenHands processes
|
||||
echo "Stopping OpenHands processes..."
|
||||
pkill -f "python -m openhands.server" || true
|
||||
pkill -f "npm run dev" || true
|
||||
pkill -f "make run" || true
|
||||
|
||||
# Print process status for debugging
|
||||
echo "Checking if any OpenHands processes are still running:"
|
||||
ps aux | grep -E "openhands|npm run dev" || true
|
||||
@@ -51,8 +51,6 @@ jobs:
|
||||
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest --forked -n auto -svv ./tests/unit
|
||||
- name: Run Runtime Tests with CLIRuntime
|
||||
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -svv tests/runtime/test_bash.py
|
||||
- name: Run E2E Tests
|
||||
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest -svv tests/e2e
|
||||
|
||||
# Run specific Windows python tests
|
||||
test-on-windows:
|
||||
|
||||
@@ -254,3 +254,6 @@ containers/runtime/Dockerfile
|
||||
containers/runtime/project.tar.gz
|
||||
containers/runtime/code
|
||||
**/node_modules/
|
||||
|
||||
# test results
|
||||
test-results
|
||||
|
||||
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.52-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.53-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -79,17 +79,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
|
||||
You can also run OpenHands directly with Docker:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.52
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.53
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.52
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.53
|
||||
```
|
||||
|
||||
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
|
||||
|
||||
@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
|
||||
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.52
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.53
|
||||
```
|
||||
|
||||
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.52-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.53-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 28 KiB |
@@ -78,6 +78,14 @@ description: Complete guide for setting up Jira Data Center integration with Ope
|
||||
- **Service Account API Key**: The personal access token from Step 2 above
|
||||
- Ensure **Active** toggle is enabled
|
||||
|
||||
<Note>
|
||||
Workspace name is the host name of your Jira Data Center instance.
|
||||
|
||||
Eg: http://jira.all-hands.dev/projects/OH/issues/OH-77
|
||||
|
||||
Here the workspace name is **jira.all-hands.dev**.
|
||||
</Note>
|
||||
|
||||
3. **Complete OAuth Flow**
|
||||
- You'll be redirected to Jira Data Center to complete OAuth verification
|
||||
- Grant the necessary permissions to verify your workspace access. If you have access to multiple workspaces, select the correct one that you initially provided
|
||||
@@ -101,18 +109,18 @@ description: Complete guide for setting up Jira Data Center integration with Ope
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Workspace link flow">
|
||||

|
||||

|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Workspace Configure flow">
|
||||

|
||||

|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Edit view as a user">
|
||||

|
||||

|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Edit view as the workspace creator">
|
||||

|
||||

|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -15,28 +15,27 @@ description: Complete guide for setting up Jira Cloud integration with OpenHands
|
||||
- Go to **Directory** > **Users**
|
||||
|
||||
2. **Create OpenHands Service Account**
|
||||
- Click **Add user**
|
||||
- Email: `openhands@yourcompany.com` (replace with your preferred service account email)
|
||||
- Display name: `OpenHands Agent`
|
||||
- Send invitation: **No** (you'll set password manually)
|
||||
- Click **Add user**
|
||||
|
||||
3. **Configure Account**
|
||||
- Locate the created user and click on it
|
||||
- Set a secure password
|
||||
- Add to relevant Jira projects with appropriate permissions
|
||||
- Click **Service accounts**
|
||||
- Click **Create a service account**
|
||||
- Name: `OpenHands Agent`
|
||||
- Click **Next**
|
||||
- Select **User** role for Jira app
|
||||
- Click **Create**
|
||||
|
||||
### Step 2: Generate API Token
|
||||
|
||||
1. **Access API Token Management**
|
||||
- Log in as the OpenHands service account
|
||||
- Go to [API Tokens](https://id.atlassian.com/manage-profile/security/api-tokens)
|
||||
|
||||
2. **Create API Token**
|
||||
1. **Access Service Account Configuration**
|
||||
- Locate the created service account from above step and click on it
|
||||
- Click **Create API token**
|
||||
- Label: `OpenHands Cloud Integration`
|
||||
- Expiry: Set appropriate expiration (recommend 1 year)
|
||||
- Click **Create**
|
||||
- Set the expiry to 365 days (maximum allowed value)
|
||||
- Click **Next**
|
||||
- In **Select token scopes** screen, filter by following values
|
||||
- App: Jira
|
||||
- Scope type: Classic
|
||||
- Scope actions: Write, Read
|
||||
- Select `read:jira-work` and `write:jira-work` scopes
|
||||
- Click **Next**
|
||||
- Review and create API token
|
||||
- **Important**: Copy and securely store the token immediately
|
||||
|
||||
### Step 3: Configure Webhook
|
||||
@@ -83,6 +82,14 @@ description: Complete guide for setting up Jira Cloud integration with OpenHands
|
||||
- **Service Account API Key**: The API token from Step 2 above
|
||||
- Ensure **Active** toggle is enabled
|
||||
|
||||
<Note>
|
||||
Workspace name is the host name when accessing a resource in Jira Cloud.
|
||||
|
||||
Eg: https://all-hands.atlassian.net/browse/OH-55
|
||||
|
||||
Here the workspace name is **all-hands**.
|
||||
</Note>
|
||||
|
||||
3. **Complete OAuth Flow**
|
||||
- You'll be redirected to Jira Cloud to complete OAuth verification
|
||||
- Grant the necessary permissions to verify your workspace access.
|
||||
@@ -106,18 +113,18 @@ description: Complete guide for setting up Jira Cloud integration with OpenHands
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Workspace link flow">
|
||||

|
||||

|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Workspace Configure flow">
|
||||

|
||||

|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Edit view as a user">
|
||||

|
||||

|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Edit view as the workspace creator">
|
||||

|
||||

|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -28,7 +28,7 @@ description: Complete guide for setting up Linear integration with OpenHands Clo
|
||||
|
||||
1. **Access API Settings**
|
||||
- Log in as the service account
|
||||
- Go to **Settings** > **API**
|
||||
- Go to **Settings** > **Security & access**
|
||||
|
||||
2. **Create Personal API Key**
|
||||
- Click **Create new key**
|
||||
@@ -82,6 +82,14 @@ description: Complete guide for setting up Linear integration with OpenHands Clo
|
||||
- **Service Account API Key**: The API key from Step 2 above
|
||||
- Ensure **Active** toggle is enabled
|
||||
|
||||
<Note>
|
||||
Workspace name is the identifier after the host name when accessing a resource in Linear.
|
||||
|
||||
Eg: https://linear.app/allhands/issue/OH-37
|
||||
|
||||
Here the workspace name is **allhands**.
|
||||
</Note>
|
||||
|
||||
3. **Complete OAuth Flow**
|
||||
- You'll be redirected to Linear to complete OAuth verification
|
||||
- Grant the necessary permissions to verify your workspace access. If you have access to multiple workspaces, select the correct one that you initially provided
|
||||
@@ -105,15 +113,15 @@ description: Complete guide for setting up Linear integration with OpenHands Clo
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Workspace link flow">
|
||||

|
||||

|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Workspace Configure flow">
|
||||

|
||||

|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Edit view as a user">
|
||||

|
||||

|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Edit view as the workspace creator">
|
||||
|
||||
@@ -58,17 +58,18 @@ The OpenHands agent needs to identify which Git repository to work with when pro
|
||||
|
||||
### Platform Configuration Issues
|
||||
- **Webhook not triggering**: Verify the webhook URL is correct and the proper event types are selected (Comment, Issue updated)
|
||||
- **API authentication failing**: Check API key/token validity and ensure required scopes are granted
|
||||
- **API authentication failing**: Check API key/token validity and ensure required scopes are granted. If your current API token is expired, make sure to update it in the respective integration settings
|
||||
- **Permission errors**: Ensure the service account has access to relevant projects/teams and appropriate permissions
|
||||
|
||||
### Workspace Integration Issues
|
||||
- **Workspace linking requests credentials**: If there are no active workspace integrations for the workspace you specified, you need to configure it first. Contact your platform administrator that you want to integrate with (eg: Jira, Linear)
|
||||
- **OAuth flow fails**: Ensure you're signing in with the same Git provider account that contains the repositories you want OpenHands to work on
|
||||
- **Integration not found**: Verify the workspace name matches exactly and that platform configuration was completed first
|
||||
- **OAuth flow fails**: Make sure that you're authorizing with the correct account with proper workspace access
|
||||
|
||||
### General Issues
|
||||
- **Agent not responding**: Check webhook logs in your platform settings and verify service account status
|
||||
- **Authentication errors**: Verify Git provider permissions and OpenHands Cloud access
|
||||
- **Agent fails to identify git repo**: Ensure you're signing in with the same Git provider account that contains the repositories you want OpenHands to work on
|
||||
- **Partial functionality**: Ensure both platform configuration and workspace integration are properly completed
|
||||
|
||||
### Getting Help
|
||||
|
||||
@@ -119,7 +119,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -128,7 +128,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.52 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.53 \
|
||||
python -m openhands.cli.main --override-cli-mode true
|
||||
```
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export GITHUB_TOKEN="your-token" # Required for repository operations
|
||||
# Run OpenHands
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -73,7 +73,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.52 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.53 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
|
||||
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.52
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.53
|
||||
```
|
||||
|
||||
2. Wait until the server is running (see log below):
|
||||
```
|
||||
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.52
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.53
|
||||
Starting OpenHands...
|
||||
Running OpenHands as root
|
||||
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
|
||||
|
||||
@@ -109,17 +109,17 @@ Note that you'll still need `uv` installed for the default MCP servers to work p
|
||||
<Accordion title="Docker Command (Click to expand)">
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.52
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.53
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
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";
|
||||
@@ -17,4 +17,63 @@ describe("Translations", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
@@ -36,6 +36,8 @@ vi.mock("react-i18next", async () => {
|
||||
"SETTINGS$NAV_API_KEYS": "API Keys",
|
||||
"SETTINGS$NAV_LLM": "LLM",
|
||||
"SETTINGS$NAV_USER": "User",
|
||||
"SETTINGS$NAV_SECRETS": "Secrets",
|
||||
"SETTINGS$NAV_MCP": "MCP",
|
||||
"SETTINGS$TITLE": "Settings"
|
||||
};
|
||||
return translations[key] || key;
|
||||
@@ -47,8 +49,33 @@ vi.mock("react-i18next", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock useConfig hook
|
||||
const { mockUseConfig } = vi.hoisted(() => ({
|
||||
mockUseConfig: vi.fn(),
|
||||
}));
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: mockUseConfig,
|
||||
}));
|
||||
|
||||
describe("Settings Billing", () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
beforeEach(() => {
|
||||
// Set default config to OSS mode
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "oss",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
const RoutesStub = createRoutesStub([
|
||||
{
|
||||
@@ -79,19 +106,7 @@ describe("Settings Billing", () => {
|
||||
});
|
||||
|
||||
it("should not render the credits tab if OSS mode", async () => {
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
},
|
||||
});
|
||||
|
||||
// OSS mode is set by default in beforeEach
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
@@ -100,17 +115,20 @@ describe("Settings Billing", () => {
|
||||
});
|
||||
|
||||
it("should render the credits tab if SaaS mode and billing is enabled", async () => {
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: true,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: true,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
@@ -121,17 +139,20 @@ describe("Settings Billing", () => {
|
||||
|
||||
it("should render the billing settings if clicking the credits item", async () => {
|
||||
const user = userEvent.setup();
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: true,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: true,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
@@ -19,6 +19,9 @@ vi.mock("react-i18next", async () => {
|
||||
SETTINGS$NAV_CREDITS: "Credits",
|
||||
SETTINGS$NAV_API_KEYS: "API Keys",
|
||||
SETTINGS$NAV_LLM: "LLM",
|
||||
SETTINGS$NAV_SECRETS: "Secrets",
|
||||
SETTINGS$NAV_MCP: "MCP",
|
||||
SETTINGS$NAV_USER: "User",
|
||||
SETTINGS$TITLE: "Settings",
|
||||
};
|
||||
return translations[key] || key;
|
||||
@@ -119,22 +122,21 @@ describe("Settings Screen", () => {
|
||||
});
|
||||
|
||||
it("should render the saas navbar", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return app mode
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
const saasConfig = { APP_MODE: "saas" };
|
||||
|
||||
// Clear any existing query data and set the config
|
||||
mockQueryClient.clear();
|
||||
mockQueryClient.setQueryData(["config"], saasConfig);
|
||||
|
||||
const sectionsToInclude = [
|
||||
"user",
|
||||
"integrations",
|
||||
"application",
|
||||
"credits", // The nav item shows "credits" text but routes to /billing
|
||||
"secrets",
|
||||
"api keys",
|
||||
];
|
||||
const sectionsToExclude = ["llm"];
|
||||
|
||||
// Clear any existing query data
|
||||
mockQueryClient.clear();
|
||||
const sectionsToExclude = ["llm", "mcp"];
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
@@ -151,8 +153,6 @@ describe("Settings Screen", () => {
|
||||
});
|
||||
expect(sectionElement).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not be able to access saas-only routes in oss mode", async () => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.52.1",
|
||||
"version": "0.53.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.52.1",
|
||||
"version": "0.53.0",
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.2",
|
||||
"@heroui/use-infinite-scroll": "^2.2.10",
|
||||
@@ -18,9 +18,9 @@
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@stripe/react-stripe-js": "^3.9.0",
|
||||
"@stripe/stripe-js": "^7.8.0",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-query": "^5.84.2",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@tanstack/react-query": "^5.85.3",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
@@ -30,14 +30,14 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.23.12",
|
||||
"i18next": "^25.3.2",
|
||||
"i18next": "^25.3.6",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.29",
|
||||
"jose": "^6.0.12",
|
||||
"lucide-react": "^0.539.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.259.0",
|
||||
"posthog-js": "^1.260.1",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -60,8 +60,8 @@
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/traverse": "^7.28.0",
|
||||
"@babel/parser": "^7.28.3",
|
||||
"@babel/traverse": "^7.28.3",
|
||||
"@babel/types": "^7.28.2",
|
||||
"@mswjs/socket.io-binding": "^0.2.0",
|
||||
"@playwright/test": "^1.54.2",
|
||||
@@ -69,7 +69,7 @@
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.83.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.6.4",
|
||||
"@testing-library/jest-dom": "^6.7.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.2.0",
|
||||
@@ -226,13 +226,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz",
|
||||
"integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
|
||||
"license": "MIT",
|
||||
"version": "7.28.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
|
||||
"integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/types": "^7.28.0",
|
||||
"@babel/parser": "^7.28.3",
|
||||
"@babel/types": "^7.28.2",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
@@ -459,12 +458,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
|
||||
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
|
||||
"license": "MIT",
|
||||
"version": "7.28.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz",
|
||||
"integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.0"
|
||||
"@babel/types": "^7.28.2"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -616,17 +614,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz",
|
||||
"integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
|
||||
"license": "MIT",
|
||||
"version": "7.28.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz",
|
||||
"integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.0",
|
||||
"@babel/generator": "^7.28.3",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/parser": "^7.28.3",
|
||||
"@babel/template": "^7.27.2",
|
||||
"@babel/types": "^7.28.0",
|
||||
"@babel/types": "^7.28.2",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3405,6 +3402,15 @@
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
@@ -5899,26 +5905,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz",
|
||||
"integrity": "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==",
|
||||
"license": "MIT",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.12.tgz",
|
||||
"integrity": "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.3.0",
|
||||
"enhanced-resolve": "^5.18.1",
|
||||
"jiti": "^2.4.2",
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"enhanced-resolve": "^5.18.3",
|
||||
"jiti": "^2.5.1",
|
||||
"lightningcss": "1.30.1",
|
||||
"magic-string": "^0.30.17",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.1.11"
|
||||
"tailwindcss": "4.1.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.11.tgz",
|
||||
"integrity": "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.12.tgz",
|
||||
"integrity": "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.4",
|
||||
"tar": "^7.4.3"
|
||||
@@ -5927,28 +5931,27 @@
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.1.11",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.1.11",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.1.11",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.1.11",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.11",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.1.11",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.1.11",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.1.11",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.1.11",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.11",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.1.11"
|
||||
"@tailwindcss/oxide-android-arm64": "4.1.12",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.1.12",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.1.12",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.1.12",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.12",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.1.12",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.1.12",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.1.12",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.1.12",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.12",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.1.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.11.tgz",
|
||||
"integrity": "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.12.tgz",
|
||||
"integrity": "sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -5958,13 +5961,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.11.tgz",
|
||||
"integrity": "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.12.tgz",
|
||||
"integrity": "sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -5974,13 +5976,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.11.tgz",
|
||||
"integrity": "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.12.tgz",
|
||||
"integrity": "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -5990,13 +5991,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.11.tgz",
|
||||
"integrity": "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.12.tgz",
|
||||
"integrity": "sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@@ -6006,13 +6006,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.11.tgz",
|
||||
"integrity": "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.12.tgz",
|
||||
"integrity": "sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -6022,13 +6021,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.11.tgz",
|
||||
"integrity": "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.12.tgz",
|
||||
"integrity": "sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -6038,13 +6036,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.11.tgz",
|
||||
"integrity": "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.12.tgz",
|
||||
"integrity": "sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -6054,13 +6051,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.11.tgz",
|
||||
"integrity": "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.12.tgz",
|
||||
"integrity": "sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -6070,13 +6066,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.11.tgz",
|
||||
"integrity": "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.12.tgz",
|
||||
"integrity": "sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -6086,9 +6081,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.11.tgz",
|
||||
"integrity": "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.12.tgz",
|
||||
"integrity": "sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
@@ -6100,28 +6095,80 @@
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.4.3",
|
||||
"@emnapi/runtime": "^1.4.3",
|
||||
"@emnapi/wasi-threads": "^1.0.2",
|
||||
"@napi-rs/wasm-runtime": "^0.2.11",
|
||||
"@tybys/wasm-util": "^0.9.0",
|
||||
"@emnapi/core": "^1.4.5",
|
||||
"@emnapi/runtime": "^1.4.5",
|
||||
"@emnapi/wasi-threads": "^1.0.4",
|
||||
"@napi-rs/wasm-runtime": "^0.2.12",
|
||||
"@tybys/wasm-util": "^0.10.0",
|
||||
"tslib": "^2.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.4.5",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.0.4",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.4.5",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.0.4",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.12",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.4.3",
|
||||
"@emnapi/runtime": "^1.4.3",
|
||||
"@tybys/wasm-util": "^0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.0",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.0",
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz",
|
||||
"integrity": "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz",
|
||||
"integrity": "sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -6131,13 +6178,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.11.tgz",
|
||||
"integrity": "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.12.tgz",
|
||||
"integrity": "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -6147,16 +6193,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/postcss": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.11.tgz",
|
||||
"integrity": "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA==",
|
||||
"license": "MIT",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.12.tgz",
|
||||
"integrity": "sha512-5PpLYhCAwf9SJEeIsSmCDLgyVfdBhdBpzX1OJ87anT9IVR0Z9pjM0FNixCAUAHGnMBGB8K99SwAheXrT0Kh6QQ==",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@tailwindcss/node": "4.1.11",
|
||||
"@tailwindcss/oxide": "4.1.11",
|
||||
"@tailwindcss/node": "4.1.12",
|
||||
"@tailwindcss/oxide": "4.1.12",
|
||||
"postcss": "^8.4.41",
|
||||
"tailwindcss": "4.1.11"
|
||||
"tailwindcss": "4.1.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
@@ -6176,14 +6221,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/vite": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.11.tgz",
|
||||
"integrity": "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw==",
|
||||
"license": "MIT",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.12.tgz",
|
||||
"integrity": "sha512-4pt0AMFDx7gzIrAOIYgYP0KCBuKWqyW8ayrdiLEjoJTT4pKTjrzG/e4uzWtTLDziC+66R9wbUqZBccJalSE5vQ==",
|
||||
"dependencies": {
|
||||
"@tailwindcss/node": "4.1.11",
|
||||
"@tailwindcss/oxide": "4.1.11",
|
||||
"tailwindcss": "4.1.11"
|
||||
"@tailwindcss/node": "4.1.12",
|
||||
"@tailwindcss/oxide": "4.1.12",
|
||||
"tailwindcss": "4.1.12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.2.0 || ^6 || ^7"
|
||||
@@ -6207,22 +6251,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.83.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.83.1.tgz",
|
||||
"integrity": "sha512-OG69LQgT7jSp+5pPuCfzltq/+7l2xoweggjme9vlbCPa/d7D7zaqv5vN/S82SzSYZ4EDLTxNO1PWrv49RAS64Q==",
|
||||
"license": "MIT",
|
||||
"version": "5.85.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.3.tgz",
|
||||
"integrity": "sha512-9Ne4USX83nHmRuEYs78LW+3lFEEO2hBDHu7mrdIgAFx5Zcrs7ker3n/i8p4kf6OgKExmaDN5oR0efRD7i2J0DQ==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.85.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.0.tgz",
|
||||
"integrity": "sha512-t1HMfToVMGfwEJRya6GG7gbK0luZJd+9IySFNePL1BforU1F3LqQ3tBC2Rpvr88bOrlU6PXyMLgJD0Yzn4ztUw==",
|
||||
"license": "MIT",
|
||||
"version": "5.85.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.3.tgz",
|
||||
"integrity": "sha512-AqU8TvNh5GVIE8I+TUU0noryBRy7gOY0XhSayVXmOPll4UkZeLWKDwi0rtWOZbwLRCbyxorfJ5DIjDqE7GXpcQ==",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.83.1"
|
||||
"@tanstack/query-core": "5.85.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -6280,17 +6322,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.4.tgz",
|
||||
"integrity": "sha512-xDXgLjVunjHqczScfkCJ9iyjdNOVHvvCdqHSSxwM9L0l/wHkTRum67SDc020uAlCoqktJplgO2AAQeLP1wgqDQ==",
|
||||
"version": "6.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.7.0.tgz",
|
||||
"integrity": "sha512-RI2e97YZ7MRa+vxP4UUnMuMFL2buSsf0ollxUbTgrbPLKhMn8KVTx7raS6DYjC7v1NDVrioOvaShxsguLNISCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@adobe/css-tools": "^4.4.0",
|
||||
"aria-query": "^5.0.0",
|
||||
"css.escape": "^1.5.1",
|
||||
"dom-accessibility-api": "^0.6.3",
|
||||
"lodash": "^4.17.21",
|
||||
"picocolors": "^1.1.1",
|
||||
"redent": "^3.0.0"
|
||||
},
|
||||
@@ -11080,9 +11120,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.3.4",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.4.tgz",
|
||||
"integrity": "sha512-AHklEYFLiRRxW1Cb6zE9lfnEtYvsydRC8nRS3RSKGX3zCqZ8nLZwMaUsrb80YuccPNv2RNokDL8LkTNnp+6mDw==",
|
||||
"version": "25.3.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.6.tgz",
|
||||
"integrity": "sha512-dThZ0CTCM3sUG/qS0ZtQYZQcUI6DtBN8yBHK+SKEqihPcEYmjVWh/YJ4luic73Iq6Uxhp6q7LJJntRK5+1t7jQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -11097,7 +11137,6 @@
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
@@ -14763,10 +14802,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.259.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.259.0.tgz",
|
||||
"integrity": "sha512-6usLnJshky8fQ82ask7PIJh4BSFOU0VkRbFg8Zanm/HIlYMG1VOdRWlToA63JXeO7Bzm9TuREq1wFm5U2VEVCg==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"version": "1.260.1",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.260.1.tgz",
|
||||
"integrity": "sha512-DD8ZSRpdScacMqtqUIvMFme8lmOWkOvExG8VvjONE7Cm3xpRH5xXpfrwMJE4bayTGWKMx4ij6SfphK6dm/o2ug==",
|
||||
"dependencies": {
|
||||
"core-js": "^3.38.1",
|
||||
"fflate": "^0.4.8",
|
||||
@@ -16975,10 +17013,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz",
|
||||
"integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==",
|
||||
"license": "MIT"
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz",
|
||||
"integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA=="
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.2.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.52.1",
|
||||
"version": "0.53.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -17,9 +17,9 @@
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@stripe/react-stripe-js": "^3.9.0",
|
||||
"@stripe/stripe-js": "^7.8.0",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-query": "^5.84.2",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@tanstack/react-query": "^5.85.3",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
@@ -29,14 +29,14 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.23.12",
|
||||
"i18next": "^25.3.2",
|
||||
"i18next": "^25.3.6",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.29",
|
||||
"jose": "^6.0.12",
|
||||
"lucide-react": "^0.539.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.259.0",
|
||||
"posthog-js": "^1.260.1",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -84,8 +84,8 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/traverse": "^7.28.0",
|
||||
"@babel/parser": "^7.28.3",
|
||||
"@babel/traverse": "^7.28.3",
|
||||
"@babel/types": "^7.28.2",
|
||||
"@mswjs/socket.io-binding": "^0.2.0",
|
||||
"@playwright/test": "^1.54.2",
|
||||
@@ -93,7 +93,7 @@
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.83.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.6.4",
|
||||
"@testing-library/jest-dom": "^6.7.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.2.0",
|
||||
|
||||
@@ -100,6 +100,17 @@ export function ConfigureModal({
|
||||
}
|
||||
}, [isOpen, existingWorkspace, isWorkspaceEditable]);
|
||||
|
||||
// Helper function to get platform-specific placeholder
|
||||
const getWorkspacePlaceholder = () => {
|
||||
if (platform === "jira") {
|
||||
return I18nKey.PROJECT_MANAGEMENT$JIRA_WORKSPACE_NAME_PLACEHOLDER;
|
||||
}
|
||||
if (platform === "jira-dc") {
|
||||
return I18nKey.PROJECT_MANAGEMENT$JIRA_DC_WORKSPACE_NAME_PLACEHOLDER;
|
||||
}
|
||||
return I18nKey.PROJECT_MANAGEMENT$LINEAR_WORKSPACE_NAME_PLACEHOLDER;
|
||||
};
|
||||
|
||||
// Validation states
|
||||
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
|
||||
const [webhookSecretError, setWebhookSecretError] = useState<string | null>(
|
||||
@@ -268,8 +279,11 @@ export function ConfigureModal({
|
||||
<BaseModalDescription>
|
||||
{showConfigurationFields ? (
|
||||
<Trans
|
||||
i18nKey={I18nKey.PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION}
|
||||
i18nKey={
|
||||
I18nKey.PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION_STAGE_2
|
||||
}
|
||||
components={{
|
||||
b: <b />,
|
||||
a: (
|
||||
<a
|
||||
href="https://docs.all-hands.dev/usage/cloud/openhands-cloud"
|
||||
@@ -280,41 +294,41 @@ export function ConfigureModal({
|
||||
Check the document for more information
|
||||
</a>
|
||||
),
|
||||
b: <b />,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p className="mt-4">
|
||||
<Trans
|
||||
i18nKey={
|
||||
I18nKey.PROJECT_MANAGEMENT$IMPORTANT_WORKSPACE_INTEGRATION
|
||||
}
|
||||
components={{
|
||||
b: <b />,
|
||||
a: (
|
||||
<a
|
||||
href="https://docs.all-hands.dev/usage/cloud/openhands-cloud"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 underline"
|
||||
>
|
||||
Check the document for more information
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<Trans
|
||||
i18nKey={
|
||||
I18nKey.PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION_STAGE_1
|
||||
}
|
||||
components={{
|
||||
b: <b />,
|
||||
a: (
|
||||
<a
|
||||
href="https://docs.all-hands.dev/usage/cloud/openhands-cloud"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 underline"
|
||||
>
|
||||
Check the document for more information
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<p className="mt-4">
|
||||
{t(I18nKey.PROJECT_MANAGEMENT$WORKSPACE_NAME_HINT, {
|
||||
platform: platformName,
|
||||
})}
|
||||
</p>
|
||||
</BaseModalDescription>
|
||||
<div className="w-full flex flex-col gap-4 mt-4">
|
||||
<div className="w-full flex flex-col gap-4 mt-1">
|
||||
<div>
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<SettingsInput
|
||||
label={t(I18nKey.PROJECT_MANAGEMENT$WORKSPACE_NAME_LABEL)}
|
||||
placeholder={t(
|
||||
I18nKey.PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER,
|
||||
)}
|
||||
placeholder={t(getWorkspacePlaceholder())}
|
||||
value={workspace}
|
||||
onChange={handleWorkspaceChange}
|
||||
className="w-full"
|
||||
@@ -418,7 +432,7 @@ export function ConfigureModal({
|
||||
>
|
||||
{(() => {
|
||||
if (existingWorkspace && showConfigurationFields) {
|
||||
return t(I18nKey.PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL);
|
||||
return t(I18nKey.PROJECT_MANAGEMENT$UPDATE_BUTTON_LABEL);
|
||||
}
|
||||
return t(I18nKey.PROJECT_MANAGEMENT$CONNECT_BUTTON_LABEL);
|
||||
})()}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { NavTab } from "./nav-tab";
|
||||
import { ScrollLeftButton } from "./scroll-left-button";
|
||||
import { ScrollRightButton } from "./scroll-right-button";
|
||||
@@ -25,18 +25,12 @@ export function Container({
|
||||
children,
|
||||
className,
|
||||
}: ContainerProps) {
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
const [showScrollButtons, setShowScrollButtons] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Track container width using ResizeObserver
|
||||
useTrackElementWidth({
|
||||
elementRef: containerRef,
|
||||
callback: setContainerWidth,
|
||||
});
|
||||
|
||||
// Check scroll position and update button states
|
||||
const updateScrollButtons = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
@@ -47,10 +41,19 @@ export function Container({
|
||||
}
|
||||
};
|
||||
|
||||
// Update scroll buttons when tabs change or container width changes
|
||||
useEffect(() => {
|
||||
updateScrollButtons();
|
||||
}, [labels, containerWidth]);
|
||||
// Track container width using ResizeObserver
|
||||
useTrackElementWidth({
|
||||
elementRef: containerRef,
|
||||
callback: (width: number) => {
|
||||
// Only update scroll button visibility when crossing the threshold
|
||||
const shouldShowScrollButtons =
|
||||
width < 598 && Boolean(labels) && labels!.length > 0;
|
||||
if (shouldShowScrollButtons) {
|
||||
setShowScrollButtons(shouldShowScrollButtons);
|
||||
}
|
||||
updateScrollButtons();
|
||||
},
|
||||
});
|
||||
|
||||
// Scroll functions
|
||||
const scrollLeft = () => {
|
||||
@@ -65,8 +68,6 @@ export function Container({
|
||||
}
|
||||
};
|
||||
|
||||
const showScrollButtons = containerWidth < 598 && labels && labels.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useCallback, useRef } from "react";
|
||||
|
||||
interface UseTrackElementWidthProps {
|
||||
elementRef: React.RefObject<HTMLElement | null>;
|
||||
callback: (width: number) => void;
|
||||
delay?: number; // Optional delay parameter with default
|
||||
}
|
||||
|
||||
export const useTrackElementWidth = ({
|
||||
elementRef,
|
||||
callback,
|
||||
delay = 100, // Default 100ms delay
|
||||
}: UseTrackElementWidthProps) => {
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Create debounced callback that only fires after delay
|
||||
const debouncedCallback = useCallback(
|
||||
(width: number) => {
|
||||
// Clear existing timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// Set new timeout
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
callback(width);
|
||||
}, delay);
|
||||
},
|
||||
[callback, delay],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
callback(entry.contentRect.width);
|
||||
debouncedCallback(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -21,7 +41,11 @@ export const useTrackElementWidth = ({
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Clean up timeout and observer
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
}, [debouncedCallback]);
|
||||
};
|
||||
|
||||
@@ -749,8 +749,15 @@ export enum I18nKey {
|
||||
PROJECT_MANAGEMENT$LINK_CONFIRMATION_TITLE = "PROJECT_MANAGEMENT$LINK_CONFIRMATION_TITLE",
|
||||
PROJECT_MANAGEMENT$CONFIGURE_BUTTON_LABEL = "PROJECT_MANAGEMENT$CONFIGURE_BUTTON_LABEL",
|
||||
PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL = "PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL",
|
||||
PROJECT_MANAGEMENT$UPDATE_BUTTON_LABEL = "PROJECT_MANAGEMENT$UPDATE_BUTTON_LABEL",
|
||||
PROJECT_MANAGEMENT$CONFIGURE_MODAL_TITLE = "PROJECT_MANAGEMENT$CONFIGURE_MODAL_TITLE",
|
||||
PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION_STAGE_1 = "PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION_STAGE_1",
|
||||
PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION_STAGE_2 = "PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION_STAGE_2",
|
||||
PROJECT_MANAGEMENT$WORKSPACE_NAME_HINT = "PROJECT_MANAGEMENT$WORKSPACE_NAME_HINT",
|
||||
PROJECT_MANAGEMENT$WORKSPACE_NAME_LABEL = "PROJECT_MANAGEMENT$WORKSPACE_NAME_LABEL",
|
||||
PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER = "PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER",
|
||||
PROJECT_MANAGEMENT$JIRA_WORKSPACE_NAME_PLACEHOLDER = "PROJECT_MANAGEMENT$JIRA_WORKSPACE_NAME_PLACEHOLDER",
|
||||
PROJECT_MANAGEMENT$JIRA_DC_WORKSPACE_NAME_PLACEHOLDER = "PROJECT_MANAGEMENT$JIRA_DC_WORKSPACE_NAME_PLACEHOLDER",
|
||||
PROJECT_MANAGEMENT$LINEAR_WORKSPACE_NAME_PLACEHOLDER = "PROJECT_MANAGEMENT$LINEAR_WORKSPACE_NAME_PLACEHOLDER",
|
||||
PROJECT_MANAGEMENT$WEBHOOK_SECRET_LABEL = "PROJECT_MANAGEMENT$WEBHOOK_SECRET_LABEL",
|
||||
PROJECT_MANAGEMENT$WEBHOOK_SECRET_PLACEHOLDER = "PROJECT_MANAGEMENT$WEBHOOK_SECRET_PLACEHOLDER",
|
||||
PROJECT_MANAGEMENT$SERVICE_ACCOUNT_EMAIL_LABEL = "PROJECT_MANAGEMENT$SERVICE_ACCOUNT_EMAIL_LABEL",
|
||||
@@ -759,9 +766,6 @@ export enum I18nKey {
|
||||
PROJECT_MANAGEMENT$SERVICE_ACCOUNT_API_PLACEHOLDER = "PROJECT_MANAGEMENT$SERVICE_ACCOUNT_API_PLACEHOLDER",
|
||||
PROJECT_MANAGEMENT$CONNECT_BUTTON_LABEL = "PROJECT_MANAGEMENT$CONNECT_BUTTON_LABEL",
|
||||
PROJECT_MANAGEMENT$ACTIVE_TOGGLE_LABEL = "PROJECT_MANAGEMENT$ACTIVE_TOGGLE_LABEL",
|
||||
PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION = "PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION",
|
||||
PROJECT_MANAGEMENT$CONFIGURE_MODAL_TITLE = "PROJECT_MANAGEMENT$CONFIGURE_MODAL_TITLE",
|
||||
PROJECT_MANAGEMENT$IMPORTANT_WORKSPACE_INTEGRATION = "PROJECT_MANAGEMENT$IMPORTANT_WORKSPACE_INTEGRATION",
|
||||
PROJECT_MANAGEMENT$WORKSPACE_NAME_VALIDATION_ERROR = "PROJECT_MANAGEMENT$WORKSPACE_NAME_VALIDATION_ERROR",
|
||||
PROJECT_MANAGEMENT$WEBHOOK_SECRET_NAME_VALIDATION_ERROR = "PROJECT_MANAGEMENT$WEBHOOK_SECRET_NAME_VALIDATION_ERROR",
|
||||
PROJECT_MANAGEMENT$SVC_ACC_EMAIL_VALIDATION_ERROR = "PROJECT_MANAGEMENT$SVC_ACC_EMAIL_VALIDATION_ERROR",
|
||||
|
||||
@@ -27,7 +27,15 @@ i18n
|
||||
.init({
|
||||
fallbackLng: "en",
|
||||
debug: import.meta.env.NODE_ENV === "development",
|
||||
load: "currentOnly",
|
||||
|
||||
// Define supported languages explicitly to prevent 404 errors
|
||||
// According to i18next documentation, this is the recommended way to prevent
|
||||
// 404 requests for unsupported language codes like 'en-US@posix'
|
||||
supportedLngs: AvailableLanguages.map((lang) => lang.value),
|
||||
|
||||
// Do NOT set nonExplicitSupportedLngs: true as it causes 404 errors
|
||||
// for region-specific codes not in supportedLngs (per i18next developer)
|
||||
nonExplicitSupportedLngs: false,
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
|
||||
@@ -11983,6 +11983,86 @@
|
||||
"de": "Bearbeiten",
|
||||
"uk": "Редагувати"
|
||||
},
|
||||
"PROJECT_MANAGEMENT$UPDATE_BUTTON_LABEL": {
|
||||
"en": "Update",
|
||||
"ja": "更新",
|
||||
"zh-CN": "更新",
|
||||
"zh-TW": "更新",
|
||||
"ko-KR": "업데이트",
|
||||
"no": "Oppdater",
|
||||
"it": "Aggiorna",
|
||||
"pt": "Atualizar",
|
||||
"es": "Actualizar",
|
||||
"ar": "تحديث",
|
||||
"fr": "Mettre à jour",
|
||||
"tr": "Güncelle",
|
||||
"de": "Aktualisieren",
|
||||
"uk": "Оновити"
|
||||
},
|
||||
"PROJECT_MANAGEMENT$CONFIGURE_MODAL_TITLE": {
|
||||
"en": "Configure {{platform}} Integration",
|
||||
"ja": "{{platform}}統合を設定",
|
||||
"zh-CN": "配置{{platform}}集成",
|
||||
"zh-TW": "配置{{platform}}集成",
|
||||
"ko-KR": "{{platform}} 통합 구성",
|
||||
"no": "Konfigurer {{platform}}-integrasjon",
|
||||
"it": "Configura integrazione {{platform}}",
|
||||
"pt": "Configurar integração {{platform}}",
|
||||
"es": "Configurar integración de {{platform}}",
|
||||
"ar": "تكوين تكامل {{platform}}",
|
||||
"fr": "Configurer l'intégration {{platform}}",
|
||||
"tr": "{{platform}} Entegrasyonunu Yapılandır",
|
||||
"de": "{{platform}}-Integration konfigurieren",
|
||||
"uk": "Налаштувати інтеграцію {{platform}}"
|
||||
},
|
||||
"PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION_STAGE_1": {
|
||||
"en": "<b>Important: </b>Make sure the workspace integration for your target workspace is already configured. Check the <a>documentation</a> for more information.",
|
||||
"ja": "<b>重要: </b>対象のワークスペースのワークスペース統合がすでに設定されていることを確認してください。詳しくは<a>ドキュメント</a>をご覧ください。",
|
||||
"zh-CN": "<b>重要提示:</b>请确保目标工作区的工作区集成已配置完毕。查看<a>文档</a>了解更多信息。",
|
||||
"zh-TW": "<b>重要提示:</b>請確保目標工作區的工作區整合已設定完成。查看<a>文件</a>以了解更多資訊。",
|
||||
"ko-KR": "<b>중요:</b>대상 작업 공간에 대한 작업 공간 통합이 이미 구성되어 있는지 확인하세요. 자세한 내용은 <a>설명서</a>를 참조하세요.",
|
||||
"no": "<b>Viktig:</b>Sørg for at arbeidsområdeintegrasjonen for målarbeidsområdet ditt allerede er konfigurert. Se <a>dokumentasjonen</a> for mer informasjon.",
|
||||
"it": "<b>Importante: </b>Assicurati che l'integrazione dell'area di lavoro per l'area di lavoro di destinazione sia già configurata. Consulta la <a>documentazione</a> per ulteriori informazioni.",
|
||||
"pt": "<b>Importante:</b>Certifique-se de que a integração do workspace de destino já esteja configurada. Consulte a <a>documentação</a> para obter mais informações.",
|
||||
"es": "Importante: Asegúrate de que la integración del espacio de trabajo de destino ya esté configurada. Consulta la documentación para obtener más información.",
|
||||
"ar": "<b>هام: </b>تأكد من إعداد تكامل مساحة العمل لمساحة العمل المستهدفة. <a>راجع المستند لمزيد من المعلومات</a>.",
|
||||
"fr": "<b>Important :</b>Assurez-vous que l'intégration de l'espace de travail cible est déjà configurée. Consultez la <a>documentation</a> pour plus d'informations.",
|
||||
"tr": "<b>Önemli: </b>Hedef çalışma alanınız için çalışma alanı entegrasyonunun zaten yapılandırılmış olduğundan emin olun. Daha fazla bilgi için <a>belgelere</a> bakın.",
|
||||
"de": "<b>Wichtig:</b>Stellen Sie sicher, dass die Arbeitsbereichsintegration für Ihren Zielarbeitsbereich bereits konfiguriert ist. Weitere Informationen finden Sie in der <a>Dokumentation</a>.",
|
||||
"uk": "<b>Важливо: </b>Переконайтеся, що інтеграцію робочого простору для вашого цільового робочого простору вже налаштовано. Перегляньте <a>документацію</a> для отримання додаткової інформації."
|
||||
},
|
||||
"PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION_STAGE_2": {
|
||||
"en": "<b>Important:</b> Check the <a>documentation</a> for more information about configuring the workspace integration or updating an existing integration.",
|
||||
"ja": "<b>重要:</b> ワークスペース統合の設定や既存の統合の更新について詳しくは<a>ドキュメント</a>をご確認ください。",
|
||||
"zh-CN": "<b>重要提示:</b>有关配置工作区集成或更新现有集成的更多信息,请查看<a>文档</a>。",
|
||||
"zh-TW": "<b>重要提示:</b>有關配置工作區整合或更新現有整合的更多資訊,請查看<a>文件</a>。",
|
||||
"ko-KR": "<b>중요:</b> 작업공간 통합 구성이나 기존 통합 업데이트에 대한 자세한 내용은 <a>설명서</a>를 확인하세요.",
|
||||
"no": "<b>Viktig:</b> Se <a>dokumentasjonen</a> for mer informasjon om konfigurering av arbeidsområdeintegrasjon eller oppdatering av eksisterende integrasjon.",
|
||||
"it": "<b>Importante:</b> Consulta la <a>documentazione</a> per ulteriori informazioni sulla configurazione dell'integrazione dell'area di lavoro o sull'aggiornamento di un'integrazione esistente.",
|
||||
"pt": "<b>Importante:</b> Consulte a <a>documentação</a> para obter mais informações sobre como configurar a integração do workspace ou atualizar uma integração existente.",
|
||||
"es": "<b>Importante:</b> Consulte la <a>documentación</a> para obtener más información sobre la configuración de la integración del espacio de trabajo o la actualización de una integración existente.",
|
||||
"ar": "<b>هام:</b> راجع <a>الوثائق</a> لمزيد من المعلومات حول تكوين تكامل مساحة العمل أو تحديث تكامل موجود.",
|
||||
"fr": "<b>Important :</b> Consultez la <a>documentation</a> pour plus d'informations sur la configuration de l'intégration de l'espace de travail ou la mise à jour d'une intégration existante.",
|
||||
"tr": "<b>Önemli:</b> Çalışma alanı entegrasyonunu yapılandırma veya mevcut bir entegrasyonu güncelleme hakkında daha fazla bilgi için <a>belgelere</a> bakın.",
|
||||
"de": "<b>Wichtig:</b> Weitere Informationen zur Konfiguration der Arbeitsbereichsintegration oder zur Aktualisierung einer bestehenden Integration finden Sie in der <a>Dokumentation</a>.",
|
||||
"uk": "<b>Важливо:</b> Перегляньте <a>документацію</a> для отримання додаткової інформації про налаштування інтеграції робочого простору або оновлення існуючої інтеграції."
|
||||
},
|
||||
"PROJECT_MANAGEMENT$WORKSPACE_NAME_HINT": {
|
||||
"en": "Workspace name can be found in the browser URL when you're accessing a resource (eg: issue) in {{platform}}.",
|
||||
"ja": "ワークスペース名は、{{platform}}のリソース(例:イシュー)にアクセスする際のブラウザURLで確認できます。",
|
||||
"zh-CN": "工作区名称可以在访问{{platform}}资源(如:问题)时的浏览器URL中找到。",
|
||||
"zh-TW": "工作區名稱可以在存取{{platform}}資源(如:問題)時的瀏覽器URL中找到。",
|
||||
"ko-KR": "작업공간 이름은 {{platform}}의 리소스(예: 이슈)에 액세스할 때 브라우저 URL에서 찾을 수 있습니다.",
|
||||
"no": "Arbeidsområdenavn kan finnes i nettleser-URL-en når du får tilgang til en ressurs (f.eks: sak) i {{platform}}.",
|
||||
"it": "Il nome dell'area di lavoro può essere trovato nell'URL del browser quando stai accedendo a una risorsa (es: issue) in {{platform}}.",
|
||||
"pt": "O nome do workspace pode ser encontrado na URL do navegador quando você está acessando um recurso (ex: issue) em {{platform}}.",
|
||||
"es": "El nombre del espacio de trabajo se puede encontrar en la URL del navegador cuando accedes a un recurso (ej: issue) en {{platform}}.",
|
||||
"ar": "يمكن العثور على اسم مساحة العمل في عنوان URL للمتصفح عند الوصول إلى مورد (مثل: مشكلة) في {{platform}}.",
|
||||
"fr": "Le nom de l'espace de travail peut être trouvé dans l'URL du navigateur lorsque vous accédez à une ressource (ex : issue) dans {{platform}}.",
|
||||
"tr": "Çalışma alanı adı, {{platform}}'da bir kaynağa (örn: sorun) erişirken tarayıcı URL'sinde bulunabilir.",
|
||||
"de": "Der Arbeitsbereichsname ist in der Browser-URL zu finden, wenn Sie auf eine Ressource (z.B.: Issue) in {{platform}} zugreifen.",
|
||||
"uk": "Назву робочого простору можна знайти в URL браузера під час доступу до ресурсу (наприклад: проблема) в {{platform}}."
|
||||
},
|
||||
"PROJECT_MANAGEMENT$WORKSPACE_NAME_LABEL": {
|
||||
"en": "Workspace Name",
|
||||
"ja": "ワークスペース名",
|
||||
@@ -11999,21 +12079,53 @@
|
||||
"de": "Arbeitsbereichsname",
|
||||
"uk": "Назва робочої області"
|
||||
},
|
||||
"PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER": {
|
||||
"en": "myworkspace",
|
||||
"ja": "私のワークスペース",
|
||||
"zh-CN": "我的工作空间",
|
||||
"zh-TW": "我的工作區",
|
||||
"ko-KR": "내워크스페이스",
|
||||
"no": "mittarbeidsområde",
|
||||
"it": "mioworkspace",
|
||||
"pt": "meuworkspace",
|
||||
"es": "miespaciodetrabajo",
|
||||
"ar": "مساحةعملي",
|
||||
"fr": "monworkspace",
|
||||
"tr": "benimworkspace",
|
||||
"de": "meinarbeitsbereich",
|
||||
"uk": "моя-робоча-область"
|
||||
"PROJECT_MANAGEMENT$JIRA_WORKSPACE_NAME_PLACEHOLDER": {
|
||||
"en": "yourcompany.atlassian.net",
|
||||
"ja": "yourcompany.atlassian.net",
|
||||
"zh-CN": "yourcompany.atlassian.net",
|
||||
"zh-TW": "yourcompany.atlassian.net",
|
||||
"ko-KR": "yourcompany.atlassian.net",
|
||||
"no": "yourcompany.atlassian.net",
|
||||
"it": "yourcompany.atlassian.net",
|
||||
"pt": "yourcompany.atlassian.net",
|
||||
"es": "yourcompany.atlassian.net",
|
||||
"ar": "yourcompany.atlassian.net",
|
||||
"fr": "yourcompany.atlassian.net",
|
||||
"tr": "yourcompany.atlassian.net",
|
||||
"de": "yourcompany.atlassian.net",
|
||||
"uk": "yourcompany.atlassian.net"
|
||||
},
|
||||
"PROJECT_MANAGEMENT$JIRA_DC_WORKSPACE_NAME_PLACEHOLDER": {
|
||||
"en": "jira.yourcompany.com",
|
||||
"ja": "jira.yourcompany.com",
|
||||
"zh-CN": "jira.yourcompany.com",
|
||||
"zh-TW": "jira.yourcompany.com",
|
||||
"ko-KR": "jira.yourcompany.com",
|
||||
"no": "jira.yourcompany.com",
|
||||
"it": "jira.yourcompany.com",
|
||||
"pt": "jira.yourcompany.com",
|
||||
"es": "jira.yourcompany.com",
|
||||
"ar": "jira.yourcompany.com",
|
||||
"fr": "jira.yourcompany.com",
|
||||
"tr": "jira.yourcompany.com",
|
||||
"de": "jira.yourcompany.com",
|
||||
"uk": "jira.yourcompany.com"
|
||||
},
|
||||
"PROJECT_MANAGEMENT$LINEAR_WORKSPACE_NAME_PLACEHOLDER": {
|
||||
"en": "yourcompany",
|
||||
"ja": "yourcompany",
|
||||
"zh-CN": "yourcompany",
|
||||
"zh-TW": "yourcompany",
|
||||
"ko-KR": "yourcompany",
|
||||
"no": "yourcompany",
|
||||
"it": "yourcompany",
|
||||
"pt": "yourcompany",
|
||||
"es": "yourcompany",
|
||||
"ar": "yourcompany",
|
||||
"fr": "yourcompany",
|
||||
"tr": "yourcompany",
|
||||
"de": "yourcompany",
|
||||
"uk": "yourcompany"
|
||||
},
|
||||
"PROJECT_MANAGEMENT$WEBHOOK_SECRET_LABEL": {
|
||||
"en": "Webhook Secret",
|
||||
@@ -12143,54 +12255,6 @@
|
||||
"de": "Aktiv",
|
||||
"uk": "Активний"
|
||||
},
|
||||
"PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION": {
|
||||
"en": "<b>Important:</b> Check the <a>documentation</a> for more information about configuring the workspace integration or updating an existing integration.",
|
||||
"ja": "<b>重要:</b> ワークスペース統合の設定や既存の統合の更新について詳しくは<a>ドキュメント</a>をご確認ください。",
|
||||
"zh-CN": "<b>重要提示:</b>有关配置工作区集成或更新现有集成的更多信息,请查看<a>文档</a>。",
|
||||
"zh-TW": "<b>重要提示:</b>有關配置工作區整合或更新現有整合的更多資訊,請查看<a>文件</a>。",
|
||||
"ko-KR": "<b>중요:</b> 작업공간 통합 구성이나 기존 통합 업데이트에 대한 자세한 내용은 <a>설명서</a>를 확인하세요.",
|
||||
"no": "<b>Viktig:</b> Se <a>dokumentasjonen</a> for mer informasjon om konfigurering av arbeidsområdeintegrasjon eller oppdatering av eksisterende integrasjon.",
|
||||
"it": "<b>Importante:</b> Consulta la <a>documentazione</a> per ulteriori informazioni sulla configurazione dell'integrazione dell'area di lavoro o sull'aggiornamento di un'integrazione esistente.",
|
||||
"pt": "<b>Importante:</b> Consulte a <a>documentação</a> para obter mais informações sobre como configurar a integração do workspace ou atualizar uma integração existente.",
|
||||
"es": "<b>Importante:</b> Consulte la <a>documentación</a> para obtener más información sobre la configuración de la integración del espacio de trabajo o la actualización de una integración existente.",
|
||||
"ar": "<b>هام:</b> راجع <a>الوثائق</a> لمزيد من المعلومات حول تكوين تكامل مساحة العمل أو تحديث تكامل موجود.",
|
||||
"fr": "<b>Important :</b> Consultez la <a>documentation</a> pour plus d'informations sur la configuration de l'intégration de l'espace de travail ou la mise à jour d'une intégration existante.",
|
||||
"tr": "<b>Önemli:</b> Çalışma alanı entegrasyonunu yapılandırma veya mevcut bir entegrasyonu güncelleme hakkında daha fazla bilgi için <a>belgelere</a> bakın.",
|
||||
"de": "<b>Wichtig:</b> Weitere Informationen zur Konfiguration der Arbeitsbereichsintegration oder zur Aktualisierung einer bestehenden Integration finden Sie in der <a>Dokumentation</a>.",
|
||||
"uk": "<b>Важливо:</b> Перегляньте <a>документацію</a> для отримання додаткової інформації про налаштування інтеграції робочого простору або оновлення існуючої інтеграції."
|
||||
},
|
||||
"PROJECT_MANAGEMENT$CONFIGURE_MODAL_TITLE": {
|
||||
"en": "Configure {{platform}} Integration",
|
||||
"ja": "{{platform}}統合を設定",
|
||||
"zh-CN": "配置{{platform}}集成",
|
||||
"zh-TW": "配置{{platform}}集成",
|
||||
"ko-KR": "{{platform}} 통합 구성",
|
||||
"no": "Konfigurer {{platform}}-integrasjon",
|
||||
"it": "Configura integrazione {{platform}}",
|
||||
"pt": "Configurar integração {{platform}}",
|
||||
"es": "Configurar integración de {{platform}}",
|
||||
"ar": "تكوين تكامل {{platform}}",
|
||||
"fr": "Configurer l'intégration {{platform}}",
|
||||
"tr": "{{platform}} Entegrasyonunu Yapılandır",
|
||||
"de": "{{platform}}-Integration konfigurieren",
|
||||
"uk": "Налаштувати інтеграцію {{platform}}"
|
||||
},
|
||||
"PROJECT_MANAGEMENT$IMPORTANT_WORKSPACE_INTEGRATION": {
|
||||
"en": "<b>Important: </b>Make sure the workspace integration for your target workspace is already configured. Check the <a>documentation</a> for more information.",
|
||||
"ja": "<b>重要: </b>対象のワークスペースのワークスペース統合がすでに設定されていることを確認してください。詳しくは<a>ドキュメント</a>をご覧ください。",
|
||||
"zh-CN": "<b>重要提示:</b>请确保目标工作区的工作区集成已配置完毕。查看<a>文档</a>了解更多信息。",
|
||||
"zh-TW": "<b>重要提示:</b>請確保目標工作區的工作區整合已設定完成。查看<a>文件</a>以了解更多資訊。",
|
||||
"ko-KR": "<b>중요:</b>대상 작업 공간에 대한 작업 공간 통합이 이미 구성되어 있는지 확인하세요. 자세한 내용은 <a>설명서</a>를 참조하세요.",
|
||||
"no": "<b>Viktig:</b>Sørg for at arbeidsområdeintegrasjonen for målarbeidsområdet ditt allerede er konfigurert. Se <a>dokumentasjonen</a> for mer informasjon.",
|
||||
"it": "<b>Importante: </b>Assicurati che l'integrazione dell'area di lavoro per l'area di lavoro di destinazione sia già configurata. Consulta la <a>documentazione</a> per ulteriori informazioni.",
|
||||
"pt": "<b>Importante:</b>Certifique-se de que a integração do workspace de destino já esteja configurada. Consulte a <a>documentação</a> para obter mais informações.",
|
||||
"es": "Importante: Asegúrate de que la integración del espacio de trabajo de destino ya esté configurada. Consulta la documentación para obtener más información.",
|
||||
"ar": "<b>هام: </b>تأكد من إعداد تكامل مساحة العمل لمساحة العمل المستهدفة. <a>راجع المستند لمزيد من المعلومات</a>.",
|
||||
"fr": "<b>Important :</b>Assurez-vous que l'intégration de l'espace de travail cible est déjà configurée. Consultez la <a>documentation</a> pour plus d'informations.",
|
||||
"tr": "<b>Önemli: </b>Hedef çalışma alanınız için çalışma alanı entegrasyonunun zaten yapılandırılmış olduğundan emin olun. Daha fazla bilgi için <a>belgelere</a> bakın.",
|
||||
"de": "<b>Wichtig:</b>Stellen Sie sicher, dass die Arbeitsbereichsintegration für Ihren Zielarbeitsbereich bereits konfiguriert ist. Weitere Informationen finden Sie in der <a>Dokumentation</a>.",
|
||||
"uk": "<b>Важливо: </b>Переконайтеся, що інтеграцію робочого простору для вашого цільового робочого простору вже налаштовано. Перегляньте <a>документацію</a> для отримання додаткової інформації."
|
||||
},
|
||||
"PROJECT_MANAGEMENT$WORKSPACE_NAME_VALIDATION_ERROR": {
|
||||
"en": "Workspace name can only contain letters, numbers, hyphens, and underscores",
|
||||
"ja": "ワークスペース名は文字、数字、ハイフン、アンダースコアのみ使用できます",
|
||||
|
||||
@@ -25,7 +25,7 @@ export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
|
||||
security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER,
|
||||
remote_runtime_resource_factor:
|
||||
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
|
||||
provider_tokens_set: { github: null, gitlab: null, bitbucket: null },
|
||||
provider_tokens_set: {},
|
||||
enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
|
||||
enable_sound_notifications: DEFAULT_SETTINGS.ENABLE_SOUND_NOTIFICATIONS,
|
||||
enable_proactive_conversation_starters:
|
||||
|
||||
@@ -122,5 +122,5 @@ export function getStatusCode(
|
||||
return runtimeStatus;
|
||||
}
|
||||
|
||||
return "STATUS$ERROR"; // illegal state
|
||||
return I18nKey.CHAT_INTERFACE$AGENT_ERROR_MESSAGE;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,52 @@ export default defineConfig(({ mode }) => {
|
||||
svgr(),
|
||||
tailwindcss(),
|
||||
],
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
// Pre-bundle ALL dependencies to prevent runtime optimization and page reloads
|
||||
// These are discovered during initial app load:
|
||||
"react-redux",
|
||||
"posthog-js",
|
||||
"@tanstack/react-query",
|
||||
"react-hot-toast",
|
||||
"@reduxjs/toolkit",
|
||||
"i18next",
|
||||
"i18next-http-backend",
|
||||
"i18next-browser-languagedetector",
|
||||
"react-i18next",
|
||||
"axios",
|
||||
"date-fns",
|
||||
"@uidotdev/usehooks",
|
||||
"react-icons/fa6",
|
||||
"react-icons/fa",
|
||||
"clsx",
|
||||
"tailwind-merge",
|
||||
"@heroui/react",
|
||||
"lucide-react",
|
||||
"react-select",
|
||||
"react-select/async",
|
||||
"@microlink/react-json-view",
|
||||
"socket.io-client",
|
||||
// These are discovered when launching conversations:
|
||||
"react-icons/vsc",
|
||||
"react-icons/lu",
|
||||
"react-icons/di",
|
||||
"react-icons/io5",
|
||||
"react-icons/io", // Added to prevent runtime optimization
|
||||
"@monaco-editor/react",
|
||||
"react-textarea-autosize",
|
||||
"react-markdown",
|
||||
"remark-gfm",
|
||||
"remark-breaks",
|
||||
"react-syntax-highlighter",
|
||||
"react-syntax-highlighter/dist/esm/styles/prism",
|
||||
"react-syntax-highlighter/dist/esm/styles/hljs",
|
||||
// Terminal dependencies - added to prevent runtime optimization
|
||||
"@xterm/addon-fit",
|
||||
"@xterm/xterm",
|
||||
"@xterm/xterm/css/xterm.css",
|
||||
],
|
||||
},
|
||||
server: {
|
||||
port: FE_PORT,
|
||||
host: true,
|
||||
|
||||
@@ -98,3 +98,11 @@ Your primary role is to assist users by executing commands, modifying code, and
|
||||
- Confirm whether they want it as a separate file or just in the conversation
|
||||
- Ask if they want documentation files to be included in version control
|
||||
</DOCUMENTATION>
|
||||
|
||||
<PROCESS_MANAGEMENT>
|
||||
* When terminating processes:
|
||||
- Do NOT use general keywords with commands like `pkill -f server` or `pkill -f python` as this might accidentally kill other important servers or processes
|
||||
- Always use specific keywords that uniquely identify the target process
|
||||
- Prefer using `ps aux` to find the exact process ID (PID) first, then kill that specific PID
|
||||
- When possible, use more targeted approaches like finding the PID from a pidfile or using application-specific shutdown commands
|
||||
</PROCESS_MANAGEMENT>
|
||||
|
||||
@@ -4,7 +4,7 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import toml
|
||||
import tomlkit
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
from prompt_toolkit.patch_stdout import patch_stdout
|
||||
from prompt_toolkit.shortcuts import clear, print_container
|
||||
@@ -520,7 +520,7 @@ def load_config_file(file_path: Path) -> dict:
|
||||
if file_path.exists():
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
return toml.load(f)
|
||||
return dict(tomlkit.load(f))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -530,9 +530,36 @@ def load_config_file(file_path: Path) -> dict:
|
||||
|
||||
|
||||
def save_config_file(config_data: dict, file_path: Path) -> None:
|
||||
"""Save the config file."""
|
||||
"""Save the config file with proper MCP formatting."""
|
||||
doc = tomlkit.document()
|
||||
|
||||
for key, value in config_data.items():
|
||||
if key == 'mcp':
|
||||
# Handle MCP section specially
|
||||
mcp_section = tomlkit.table()
|
||||
|
||||
for mcp_key, mcp_value in value.items():
|
||||
# Create array with inline tables for server configurations
|
||||
server_array = tomlkit.array()
|
||||
for server_config in mcp_value:
|
||||
if isinstance(server_config, dict):
|
||||
# Create inline table for each server
|
||||
inline_table = tomlkit.inline_table()
|
||||
for server_key, server_val in server_config.items():
|
||||
inline_table[server_key] = server_val
|
||||
server_array.append(inline_table)
|
||||
else:
|
||||
# Handle non-dict values (like string URLs)
|
||||
server_array.append(server_config)
|
||||
mcp_section[mcp_key] = server_array
|
||||
|
||||
doc[key] = mcp_section
|
||||
else:
|
||||
# Handle non-MCP sections normally
|
||||
doc[key] = value
|
||||
|
||||
with open(file_path, 'w') as f:
|
||||
toml.dump(config_data, f)
|
||||
f.write(tomlkit.dumps(doc))
|
||||
|
||||
|
||||
def _ensure_mcp_config_structure(config_data: dict) -> None:
|
||||
|
||||
@@ -241,7 +241,7 @@ async def modify_llm_settings_basic(
|
||||
provider_list = [p for p in provider_list if p not in verified_providers]
|
||||
provider_list = verified_providers + provider_list
|
||||
|
||||
provider_completer = FuzzyWordCompleter(provider_list)
|
||||
provider_completer = FuzzyWordCompleter(provider_list, WORD=True)
|
||||
session = PromptSession(key_bindings=kb_cancel())
|
||||
|
||||
current_provider, current_model, current_api_key = (
|
||||
@@ -392,7 +392,7 @@ async def modify_llm_settings_basic(
|
||||
)
|
||||
|
||||
if change_model:
|
||||
model_completer = FuzzyWordCompleter(provider_models)
|
||||
model_completer = FuzzyWordCompleter(provider_models, WORD=True)
|
||||
|
||||
# Define a validator function that allows custom models but shows a warning
|
||||
def model_validator(x):
|
||||
@@ -528,7 +528,7 @@ async def modify_llm_settings_advanced(
|
||||
)
|
||||
|
||||
agent_list = Agent.list_agents()
|
||||
agent_completer = FuzzyWordCompleter(agent_list)
|
||||
agent_completer = FuzzyWordCompleter(agent_list, WORD=True)
|
||||
agent = await get_validated_input(
|
||||
session,
|
||||
'(Step 4/6) Agent (TAB for options, CTRL-c to cancel): ',
|
||||
|
||||
@@ -906,7 +906,6 @@ def cli_confirm(
|
||||
layout=layout,
|
||||
key_bindings=kb,
|
||||
style=style,
|
||||
mouse_support=True,
|
||||
full_screen=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -183,3 +183,13 @@ class LLMConfig(BaseModel):
|
||||
# Azure issue: https://github.com/All-Hands-AI/OpenHands/issues/7755
|
||||
if self.model.startswith('azure') and self.api_version is None:
|
||||
self.api_version = '2024-12-01-preview'
|
||||
|
||||
# Set AWS credentials as environment variables for LiteLLM Bedrock
|
||||
if self.aws_access_key_id:
|
||||
os.environ['AWS_ACCESS_KEY_ID'] = self.aws_access_key_id.get_secret_value()
|
||||
if self.aws_secret_access_key:
|
||||
os.environ['AWS_SECRET_ACCESS_KEY'] = (
|
||||
self.aws_secret_access_key.get_secret_value()
|
||||
)
|
||||
if self.aws_region_name:
|
||||
os.environ['AWS_REGION_NAME'] = self.aws_region_name
|
||||
|
||||
@@ -19,7 +19,9 @@ class CmdRunAction(Action):
|
||||
blocking: bool = False # if True, the command will be run in a blocking manner, but a timeout must be set through _set_hard_timeout
|
||||
is_static: bool = False # if True, runs the command in a separate process
|
||||
cwd: str | None = None # current working directory, only used if is_static is True
|
||||
hidden: bool = False
|
||||
hidden: bool = (
|
||||
False # if True, this command does not go through the LLM or event stream
|
||||
)
|
||||
action: str = ActionType.RUN
|
||||
runnable: ClassVar[bool] = True
|
||||
confirmation_state: ActionConfirmationStatus = ActionConfirmationStatus.CONFIRMED
|
||||
|
||||
@@ -115,9 +115,12 @@ class CmdOutputObservation(Observation):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
# Truncate content before passing it to parent
|
||||
truncated_content = self._maybe_truncate(content)
|
||||
# Hidden commands don't go through LLM/event stream, so no need to truncate
|
||||
truncate = not hidden
|
||||
if truncate:
|
||||
content = self._maybe_truncate(content)
|
||||
|
||||
super().__init__(truncated_content)
|
||||
super().__init__(content)
|
||||
|
||||
self.command = command
|
||||
self.observation = observation
|
||||
|
||||
@@ -496,15 +496,15 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
|
||||
"""Get branches for a repository"""
|
||||
url = f'{self.BASE_URL}/repos/{repository}/branches'
|
||||
|
||||
# Set maximum branches to fetch (10 pages with 100 per page)
|
||||
MAX_BRANCHES = 1000
|
||||
# Set maximum branches to fetch (100 per page)
|
||||
MAX_BRANCHES = 5_000
|
||||
PER_PAGE = 100
|
||||
|
||||
all_branches: list[Branch] = []
|
||||
page = 1
|
||||
|
||||
# Fetch up to 10 pages of branches
|
||||
while page <= 10 and len(all_branches) < MAX_BRANCHES:
|
||||
while len(all_branches) < MAX_BRANCHES:
|
||||
params = {'per_page': str(PER_PAGE), 'page': str(page)}
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import os
|
||||
import time
|
||||
import warnings
|
||||
from functools import partial
|
||||
from threading import RLock
|
||||
from typing import Any, Callable
|
||||
|
||||
import httpx
|
||||
@@ -95,6 +94,7 @@ FUNCTION_CALLING_SUPPORTED_MODELS = [
|
||||
'kimi-k2-instruct',
|
||||
'Qwen3-Coder-480B-A35B-Instruct',
|
||||
'qwen3-coder', # this will match both qwen3-coder-480b (openhands provider) and qwen3-coder (for openrouter)
|
||||
'gpt-5',
|
||||
'gpt-5-2025-08-07',
|
||||
]
|
||||
|
||||
@@ -109,7 +109,9 @@ REASONING_EFFORT_SUPPORTED_MODELS = [
|
||||
'o4-mini-2025-04-16',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-pro',
|
||||
'gpt-5',
|
||||
'gpt-5-2025-08-07',
|
||||
'claude-opus-4-1-20250805', # we need to remove top_p for opus 4.1
|
||||
]
|
||||
|
||||
MODELS_WITHOUT_STOP_WORDS = [
|
||||
@@ -143,7 +145,6 @@ class LLM(RetryMixin, DebugMixin):
|
||||
metrics: The metrics to use.
|
||||
"""
|
||||
self._tried_model_info = False
|
||||
self._lock = RLock()
|
||||
self.metrics: Metrics = (
|
||||
metrics if metrics is not None else Metrics(model_name=config.model)
|
||||
)
|
||||
@@ -159,22 +160,11 @@ class LLM(RetryMixin, DebugMixin):
|
||||
)
|
||||
os.makedirs(self.config.log_completions_folder, exist_ok=True)
|
||||
|
||||
# Initialize core internals in a single pass
|
||||
self._initialize_core()
|
||||
|
||||
def _initialize_core(self) -> None:
|
||||
"""Initialize or re-initialize all components derived from config.
|
||||
|
||||
This centralizes initialization to avoid duplication between __init__ and reinit().
|
||||
"""
|
||||
# call init_model_info to initialize config.max_output_tokens used in partial
|
||||
# call init_model_info to initialize config.max_output_tokens
|
||||
# which is used in partial function
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('ignore')
|
||||
self.init_model_info()
|
||||
|
||||
# Recompute function-calling capability regardless of model_info cache
|
||||
self._compute_function_calling_active()
|
||||
|
||||
if self.vision_is_active():
|
||||
logger.debug('LLM: model has vision enabled')
|
||||
if self.is_caching_prompt_active():
|
||||
@@ -188,17 +178,6 @@ class LLM(RetryMixin, DebugMixin):
|
||||
else:
|
||||
self.tokenizer = None
|
||||
|
||||
# Initialize the completion function
|
||||
self._build_completion_function()
|
||||
# Build the completion wrapper with retry logic
|
||||
self._rebuild_completion_wrapper()
|
||||
|
||||
def _build_completion_function(self) -> None:
|
||||
"""Build the completion function based on current configuration.
|
||||
|
||||
This method creates the partial function that will be used for LLM completions.
|
||||
It can be called multiple times to rebuild the function when configuration changes.
|
||||
"""
|
||||
# set up the completion function
|
||||
kwargs: dict[str, Any] = {
|
||||
'temperature': self.config.temperature,
|
||||
@@ -275,78 +254,6 @@ class LLM(RetryMixin, DebugMixin):
|
||||
|
||||
self._completion_unwrapped = self._completion
|
||||
|
||||
def reinit(self, new_config: LLMConfig) -> None:
|
||||
"""Reinitialize the LLM with a new configuration.
|
||||
|
||||
This is a threadsafe operation that updates configuration and rebuilds
|
||||
the completion pipeline (completion function + retry wrapper). It also
|
||||
refreshes model info and tokenizer as needed.
|
||||
"""
|
||||
with self._lock:
|
||||
# Reset capability/cost flags so the new config gets a clean slate
|
||||
self.cost_metric_supported = True
|
||||
old_model = self.config.model
|
||||
old_tokenizer = self.config.custom_tokenizer
|
||||
|
||||
# Update the configuration (deep copy to avoid external mutation)
|
||||
self.config = copy.deepcopy(new_config)
|
||||
|
||||
# Update metrics model name if model changed and refresh model info
|
||||
if old_model != new_config.model:
|
||||
self.metrics.model_name = new_config.model
|
||||
logger.debug(
|
||||
f'LLM model changed from {old_model} to {new_config.model}'
|
||||
)
|
||||
# Reset model info to force re-initialization
|
||||
self._tried_model_info = False
|
||||
self.model_info = None
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('ignore')
|
||||
self.init_model_info()
|
||||
# Log new capabilities
|
||||
if self.vision_is_active():
|
||||
logger.debug('LLM: model has vision enabled')
|
||||
if self.is_caching_prompt_active():
|
||||
logger.debug('LLM: caching prompt enabled')
|
||||
if self.is_function_calling_active():
|
||||
logger.debug('LLM: model supports function calling')
|
||||
|
||||
# Update tokenizer if custom_tokenizer changed
|
||||
if old_tokenizer != new_config.custom_tokenizer:
|
||||
if new_config.custom_tokenizer is not None:
|
||||
self.tokenizer = create_pretrained_tokenizer(
|
||||
new_config.custom_tokenizer
|
||||
)
|
||||
logger.debug(
|
||||
f'LLM tokenizer updated to {new_config.custom_tokenizer}'
|
||||
)
|
||||
else:
|
||||
self.tokenizer = None
|
||||
logger.debug('LLM tokenizer reset to default')
|
||||
|
||||
# Handle log completions folder creation if needed
|
||||
if new_config.log_completions:
|
||||
if new_config.log_completions_folder is None:
|
||||
raise RuntimeError(
|
||||
'log_completions_folder is required when log_completions is enabled'
|
||||
)
|
||||
os.makedirs(new_config.log_completions_folder, exist_ok=True)
|
||||
|
||||
# Re-initialize core internals (model info, tokenizer, completion & wrapper)
|
||||
self._initialize_core()
|
||||
logger.debug('LLM reinitialized successfully')
|
||||
|
||||
# Backward-compat: keep update_config as an alias
|
||||
def update_config(self, new_config: LLMConfig) -> None:
|
||||
self.reinit(new_config)
|
||||
|
||||
def _rebuild_completion_wrapper(self) -> None:
|
||||
"""Rebuild the completion wrapper with retry decorator.
|
||||
|
||||
This method recreates the wrapper function that includes retry logic
|
||||
and other processing around the base completion function.
|
||||
"""
|
||||
|
||||
@self.retry_decorator(
|
||||
num_retries=self.config.num_retries,
|
||||
retry_exceptions=LLM_RETRY_EXCEPTIONS,
|
||||
@@ -649,19 +556,14 @@ class LLM(RetryMixin, DebugMixin):
|
||||
self.config.max_output_tokens = self.model_info['max_tokens']
|
||||
|
||||
# Initialize function calling capability
|
||||
self._compute_function_calling_active()
|
||||
|
||||
def _compute_function_calling_active(self) -> None:
|
||||
"""Compute and cache whether function calling is active for current config.
|
||||
|
||||
Respects user override via native_tool_calling. Otherwise bases decision on
|
||||
supported model names.
|
||||
"""
|
||||
# Check if model name is in our supported list
|
||||
model_name_supported = (
|
||||
self.config.model in FUNCTION_CALLING_SUPPORTED_MODELS
|
||||
or self.config.model.split('/')[-1] in FUNCTION_CALLING_SUPPORTED_MODELS
|
||||
or any(m in self.config.model for m in FUNCTION_CALLING_SUPPORTED_MODELS)
|
||||
)
|
||||
|
||||
# Handle native_tool_calling user-defined configuration
|
||||
if self.config.native_tool_calling is None:
|
||||
self._function_calling_active = model_name_supported
|
||||
else:
|
||||
|
||||
@@ -1038,7 +1038,9 @@ fi
|
||||
self, command: str, cwd: str | None
|
||||
) -> CommandResult:
|
||||
"""This function is used by the GitHandler to execute shell commands."""
|
||||
obs = self.run(CmdRunAction(command=command, is_static=True, cwd=cwd))
|
||||
obs = self.run(
|
||||
CmdRunAction(command=command, is_static=True, hidden=True, cwd=cwd)
|
||||
)
|
||||
exit_code = 0
|
||||
content = ''
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class DockerRuntimeBuilder(RuntimeBuilder):
|
||||
|
||||
@staticmethod
|
||||
def check_buildx(is_podman: bool = False) -> bool:
|
||||
"""Check if Docker Buildx is available"""
|
||||
"""Check if Docker Buildx is available."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['docker' if not is_podman else 'podman', 'buildx', 'version'],
|
||||
@@ -176,29 +176,32 @@ class DockerRuntimeBuilder(RuntimeBuilder):
|
||||
bufsize=1,
|
||||
)
|
||||
|
||||
output_lines = []
|
||||
if process.stdout:
|
||||
for line in iter(process.stdout.readline, ''):
|
||||
line = line.strip()
|
||||
if line:
|
||||
output_lines.append(line) # Store all output lines
|
||||
self._output_logs(line)
|
||||
|
||||
return_code = process.wait()
|
||||
|
||||
if return_code != 0:
|
||||
# Use the collected output for error reporting
|
||||
output_str = '\n'.join(output_lines)
|
||||
raise subprocess.CalledProcessError(
|
||||
return_code,
|
||||
process.args,
|
||||
output=process.stdout.read() if process.stdout else None,
|
||||
stderr=process.stderr.read() if process.stderr else None,
|
||||
output=output_str, # Use the collected output
|
||||
stderr=None,
|
||||
)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f'Image build failed:\n{e}') # TODO: {e} is empty
|
||||
logger.error(f'Command output:\n{e.output}')
|
||||
if self.rolling_logger.is_enabled():
|
||||
logger.error(
|
||||
'Docker build output:\n' + self.rolling_logger.all_lines
|
||||
) # Show the error
|
||||
logger.error(f'Image build failed with exit code {e.returncode}')
|
||||
if e.output:
|
||||
logger.error(f'Command output:\n{e.output}')
|
||||
elif self.rolling_logger.is_enabled() and self.rolling_logger.all_lines:
|
||||
logger.error(f'Docker build output:\n{self.rolling_logger.all_lines}')
|
||||
raise
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
|
||||
@@ -322,6 +322,8 @@ class ActionExecutionClient(Runtime):
|
||||
)
|
||||
assert response.is_closed
|
||||
output = response.json()
|
||||
if getattr(action, 'hidden', False):
|
||||
output.get('extras')['hidden'] = True
|
||||
obs = observation_from_dict(output)
|
||||
obs._cause = action.id # type: ignore[attr-defined]
|
||||
except httpx.TimeoutException:
|
||||
|
||||
@@ -40,7 +40,7 @@ Two configuration options are required to use the Kubernetes runtime:
|
||||
2. **Runtime Container Image**: Specify the container image to use for the runtime environment
|
||||
```toml
|
||||
[sandbox]
|
||||
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik"
|
||||
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik"
|
||||
```
|
||||
|
||||
#### Additional Kubernetes Options
|
||||
|
||||
@@ -312,7 +312,11 @@ class BashSession:
|
||||
return command_output.rstrip()
|
||||
|
||||
def _handle_completed_command(
|
||||
self, command: str, pane_content: str, ps1_matches: list[re.Match]
|
||||
self,
|
||||
command: str,
|
||||
pane_content: str,
|
||||
ps1_matches: list[re.Match],
|
||||
hidden: bool,
|
||||
) -> CmdOutputObservation:
|
||||
is_special_key = self._is_special_key(command)
|
||||
assert len(ps1_matches) >= 1, (
|
||||
@@ -359,6 +363,7 @@ class BashSession:
|
||||
content=command_output,
|
||||
command=command,
|
||||
metadata=metadata,
|
||||
hidden=hidden,
|
||||
)
|
||||
|
||||
def _handle_nochange_timeout_command(
|
||||
@@ -549,11 +554,9 @@ class BashSession:
|
||||
metadata = CmdOutputMetadata() # No metadata available
|
||||
metadata.suffix = (
|
||||
f'\n[Your command "{command}" is NOT executed. '
|
||||
f'The previous command is still running - You CANNOT send new commands until the previous command is completed. '
|
||||
'The previous command is still running - You CANNOT send new commands until the previous command is completed. '
|
||||
'By setting `is_input` to `true`, you can interact with the current process: '
|
||||
"You may wait longer to see additional output of the previous command by sending empty command '', "
|
||||
'send other commands to interact with the current process, '
|
||||
'or send keys ("C-c", "C-z", "C-d") to interrupt/kill the previous command before sending your new command.]'
|
||||
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
|
||||
)
|
||||
logger.debug(f'PREVIOUS COMMAND OUTPUT: {raw_command_output}')
|
||||
command_output = self._get_command_output(
|
||||
@@ -566,6 +569,7 @@ class BashSession:
|
||||
command=command,
|
||||
content=command_output,
|
||||
metadata=metadata,
|
||||
hidden=getattr(action, 'hidden', False),
|
||||
)
|
||||
|
||||
# Send actual command/inputs to the pane
|
||||
@@ -616,6 +620,7 @@ class BashSession:
|
||||
command,
|
||||
pane_content=cur_pane_output,
|
||||
ps1_matches=ps1_matches,
|
||||
hidden=getattr(action, 'hidden', False),
|
||||
)
|
||||
|
||||
# Timeout checks should only trigger if a new prompt hasn't appeared yet.
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
TIMEOUT_MESSAGE_TEMPLATE = (
|
||||
"You may wait longer to see additional output by sending empty command '', "
|
||||
'send other commands to interact with the current process, '
|
||||
'send keys to interrupt/kill the command, '
|
||||
'send keys ("C-c", "C-z", "C-d") to interrupt/kill the previous command before sending your new command, '
|
||||
'or use the timeout parameter in execute_bash for future commands.'
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
FROM {{ base_image }}
|
||||
|
||||
SHELL ["/bin/bash", "-c"]
|
||||
|
||||
# Shared environment variables
|
||||
ENV POETRY_VIRTUALENVS_PATH=/openhands/poetry \
|
||||
MAMBA_ROOT_PREFIX=/openhands/micromamba \
|
||||
@@ -94,13 +96,8 @@ RUN \
|
||||
install -m 0755 -d /etc/apt/keyrings && \
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && \
|
||||
chmod a+r /etc/apt/keyrings/docker.asc && \
|
||||
# Add the repository to Apt sources
|
||||
# For Debian, if it's noble (testing/unstable), use bookworm (stable) repository
|
||||
if [ "$(lsb_release -cs)" = "noble" ]; then \
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null; \
|
||||
else \
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null; \
|
||||
fi; \
|
||||
# Add the repository to Apt sources (default to bookworm for stability)
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null; \
|
||||
fi && \
|
||||
# Install Docker Engine, containerd, and Docker Compose
|
||||
apt-get update && \
|
||||
|
||||
@@ -855,9 +855,7 @@ class WindowsPowershellSession:
|
||||
f'\n[Your command "{command}" is NOT executed. '
|
||||
f'The previous command is still running - You CANNOT send new commands until the previous command is completed. '
|
||||
'By setting `is_input` to `true`, you can interact with the current process: '
|
||||
"You may wait longer to see additional output of the previous command by sending empty command '', "
|
||||
'send other commands to interact with the current process, '
|
||||
'or send keys ("C-c", "C-z", "C-d") to interrupt/kill the previous command before sending your new command.]'
|
||||
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
|
||||
)
|
||||
|
||||
return CmdOutputObservation(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
@@ -3462,7 +3462,6 @@ files = [
|
||||
{file = "greenlet-3.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:eeb27bece45c0c2a5842ac4c5a1b5c2ceaefe5711078eed4e8043159fa05c834"},
|
||||
{file = "greenlet-3.2.2.tar.gz", hash = "sha256:ad053d34421a2debba45aa3cc39acf454acbcd025b3fc1a9f8a0dee237abd485"},
|
||||
]
|
||||
markers = {test = "platform_python_implementation == \"CPython\""}
|
||||
|
||||
[package.extras]
|
||||
docs = ["Sphinx", "furo"]
|
||||
@@ -7063,7 +7062,7 @@ version = "1.52.0"
|
||||
description = "A high-level API to automate web browsers"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "evaluation"]
|
||||
groups = ["main", "evaluation", "test"]
|
||||
files = [
|
||||
{file = "playwright-1.52.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:19b2cb9d4794062008a635a99bd135b03ebb782d460f96534a91cb583f549512"},
|
||||
{file = "playwright-1.52.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0797c0479cbdc99607412a3c486a3a2ec9ddc77ac461259fd2878c975bcbb94a"},
|
||||
@@ -7737,7 +7736,7 @@ version = "13.0.0"
|
||||
description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "evaluation"]
|
||||
groups = ["main", "evaluation", "test"]
|
||||
files = [
|
||||
{file = "pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498"},
|
||||
{file = "pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37"},
|
||||
@@ -7975,6 +7974,25 @@ pytest = ">=8.2,<9"
|
||||
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"]
|
||||
testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-base-url"
|
||||
version = "2.1.0"
|
||||
description = "pytest plugin for URL based testing"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["test"]
|
||||
files = [
|
||||
{file = "pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6"},
|
||||
{file = "pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pytest = ">=7.0.0"
|
||||
requests = ">=2.9"
|
||||
|
||||
[package.extras]
|
||||
test = ["black (>=22.1.0)", "flake8 (>=4.0.1)", "pre-commit (>=2.17.0)", "pytest-localserver (>=0.7.1)", "tox (>=3.24.5)"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "6.2.1"
|
||||
@@ -8011,6 +8029,39 @@ files = [
|
||||
py = "*"
|
||||
pytest = ">=3.10"
|
||||
|
||||
[[package]]
|
||||
name = "pytest-playwright"
|
||||
version = "0.7.0"
|
||||
description = "A pytest wrapper with fixtures for Playwright to automate web browsers"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["test"]
|
||||
files = [
|
||||
{file = "pytest_playwright-0.7.0-py3-none-any.whl", hash = "sha256:2516d0871fa606634bfe32afbcc0342d68da2dbff97fe3459849e9c428486da2"},
|
||||
{file = "pytest_playwright-0.7.0.tar.gz", hash = "sha256:b3f2ea514bbead96d26376fac182f68dcd6571e7cb41680a89ff1673c05d60b6"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
playwright = ">=1.18"
|
||||
pytest = ">=6.2.4,<9.0.0"
|
||||
pytest-base-url = ">=1.0.0,<3.0.0"
|
||||
python-slugify = ">=6.0.0,<9.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "pytest-timeout"
|
||||
version = "2.4.0"
|
||||
description = "pytest plugin to abort hanging tests"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["test"]
|
||||
files = [
|
||||
{file = "pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2"},
|
||||
{file = "pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pytest = ">=7.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "pytest-xdist"
|
||||
version = "3.8.0"
|
||||
@@ -8177,6 +8228,24 @@ Pillow = ">=3.3.2"
|
||||
typing-extensions = ">=4.9.0"
|
||||
XlsxWriter = ">=0.5.7"
|
||||
|
||||
[[package]]
|
||||
name = "python-slugify"
|
||||
version = "8.0.4"
|
||||
description = "A Python slugify application that also handles Unicode"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["test"]
|
||||
files = [
|
||||
{file = "python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856"},
|
||||
{file = "python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
text-unidecode = ">=1.3"
|
||||
|
||||
[package.extras]
|
||||
unidecode = ["Unidecode (>=1.1.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "python-socketio"
|
||||
version = "5.13.0"
|
||||
@@ -8769,7 +8838,7 @@ version = "2.32.3"
|
||||
description = "Python HTTP for Humans."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "evaluation", "runtime"]
|
||||
groups = ["main", "evaluation", "runtime", "test"]
|
||||
files = [
|
||||
{file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"},
|
||||
{file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
|
||||
@@ -9741,7 +9810,7 @@ test = ["pytest", "pytest-cov"]
|
||||
type = "git"
|
||||
url = "https://github.com/ryanhoangt/SWE-bench.git"
|
||||
reference = "fix-modal-patch-eval"
|
||||
resolved_reference = "03846bfaa2f1bad5e72354094ac308590d5e2b37"
|
||||
resolved_reference = "aa0f1ed4c3b57828c8b9b2821b0ce74e201cf813"
|
||||
|
||||
[[package]]
|
||||
name = "swegym"
|
||||
@@ -9896,6 +9965,18 @@ aiohttp = ">=3.8,<4.0"
|
||||
huggingface-hub = ">=0.12,<1.0"
|
||||
pydantic = ">2,<3"
|
||||
|
||||
[[package]]
|
||||
name = "text-unidecode"
|
||||
version = "1.3"
|
||||
description = "The most basic Text::Unidecode port"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["test"]
|
||||
files = [
|
||||
{file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"},
|
||||
{file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tifffile"
|
||||
version = "2025.6.1"
|
||||
@@ -10655,7 +10736,7 @@ version = "2.4.0"
|
||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "evaluation", "runtime"]
|
||||
groups = ["main", "evaluation", "runtime", "test"]
|
||||
files = [
|
||||
{file = "urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813"},
|
||||
{file = "urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466"},
|
||||
@@ -11797,4 +11878,4 @@ third-party-runtimes = ["daytona", "e2b", "modal", "runloop-api-client"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "9fd177a2dfa1eebb9212e515db93c58f82d6126cc2d131de5321d68772bc2a59"
|
||||
content-hash = "dbcab8224ee537e465f51c5170d8c19e749236c7ba01268f459140c95266afd7"
|
||||
|
||||
@@ -6,7 +6,7 @@ requires = [
|
||||
|
||||
[tool.poetry]
|
||||
name = "openhands-ai"
|
||||
version = "0.52.1"
|
||||
version = "0.53.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
authors = [ "OpenHands" ]
|
||||
license = "MIT"
|
||||
@@ -126,6 +126,8 @@ pytest-cov = "*"
|
||||
pytest-asyncio = "*"
|
||||
pytest-forked = "*"
|
||||
pytest-xdist = "*"
|
||||
pytest-playwright = "^0.7.0"
|
||||
pytest-timeout = "^2.4.0"
|
||||
openai = "*"
|
||||
pandas = "*"
|
||||
reportlab = "*"
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# OpenHands End-to-End Tests
|
||||
|
||||
This directory contains end-to-end tests for the OpenHands application. These tests use Playwright to interact with the OpenHands UI and verify that the application works correctly.
|
||||
|
||||
## Running the Tests
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.12 or later
|
||||
- Poetry
|
||||
- Node.js
|
||||
- Playwright
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The following environment variables are required:
|
||||
|
||||
- `GITHUB_TOKEN`: A GitHub token with access to the repositories you want to test
|
||||
- `LLM_MODEL`: The LLM model to use (e.g., "gpt-4o")
|
||||
- `LLM_API_KEY`: The API key for the LLM model
|
||||
|
||||
Optional environment variables:
|
||||
|
||||
- `LLM_BASE_URL`: The base URL for the LLM API (if using a custom endpoint)
|
||||
|
||||
### Running Locally
|
||||
|
||||
To run the full end-to-end test suite locally:
|
||||
|
||||
```bash
|
||||
cd tests/e2e
|
||||
poetry run pytest test_e2e_workflow.py -v
|
||||
```
|
||||
|
||||
This runs all tests in sequence:
|
||||
1. GitHub token configuration
|
||||
2. Conversation start
|
||||
|
||||
### Running Individual Tests
|
||||
|
||||
You can run individual tests directly:
|
||||
|
||||
```bash
|
||||
cd tests/e2e
|
||||
# Run the GitHub token configuration test
|
||||
poetry run pytest test_e2e_workflow.py::test_github_token_configuration -v
|
||||
|
||||
# Run the conversation start test
|
||||
poetry run pytest test_e2e_workflow.py::test_conversation_start -v
|
||||
|
||||
|
||||
```
|
||||
|
||||
### Running with Visible Browser
|
||||
|
||||
To run the tests with a visible browser (non-headless mode) so you can watch the browser interactions:
|
||||
|
||||
```bash
|
||||
cd tests/e2e
|
||||
poetry run pytest test_e2e_workflow.py::test_github_token_configuration -v --no-headless --slow-mo=50
|
||||
poetry run pytest test_e2e_workflow.py::test_conversation_start -v --no-headless --slow-mo=50
|
||||
```
|
||||
|
||||
### GitHub Workflow
|
||||
|
||||
The tests can also be run as part of a GitHub workflow. The workflow is triggered by:
|
||||
|
||||
1. Adding the "end-to-end" label to a pull request
|
||||
2. Manually triggering the workflow from the GitHub Actions tab
|
||||
|
||||
## Test Descriptions
|
||||
|
||||
### GitHub Token Configuration Test
|
||||
|
||||
The GitHub token configuration test (`test_github_token_configuration`) performs the following steps:
|
||||
|
||||
1. Navigates to the OpenHands application
|
||||
2. Checks if the GitHub token is already configured:
|
||||
- If not configured, it navigates to the settings page and configures it
|
||||
- If already configured, it verifies the repository selection is available
|
||||
3. Verifies that the GitHub token is saved and the repository selection is available
|
||||
|
||||
### Conversation Start Test
|
||||
|
||||
The conversation start test (`test_conversation_start`) performs the following steps:
|
||||
|
||||
1. Navigates to the OpenHands application (assumes GitHub token is already configured)
|
||||
2. Selects the "openhands-agent/OpenHands" repository
|
||||
3. Clicks the "Launch" button
|
||||
4. Waits for the conversation interface to load
|
||||
5. Waits for the agent to initialize
|
||||
6. Asks "How many lines are there in the main README.md file?"
|
||||
7. Waits for and verifies the agent's response
|
||||
|
||||
|
||||
|
||||
### Simple Browser Navigation Test
|
||||
|
||||
A simple test (`test_simple_browser_navigation`) that just navigates to the OpenHands GitHub repository to verify the browser setup works correctly.
|
||||
|
||||
### Local Runtime Test
|
||||
|
||||
A separate test (`test_headless_mode_with_dummy_agent_no_browser` in `test_local_runtime.py`) that tests the local runtime with a dummy agent in headless mode.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If the tests fail, check the following:
|
||||
|
||||
1. Make sure all required environment variables are set
|
||||
2. Check the logs in `/tmp/openhands-e2e-test.log` and `/tmp/openhands-e2e-build.log`
|
||||
3. Verify that the OpenHands application is running correctly
|
||||
4. Check the Playwright test results in the `test-results` directory
|
||||
@@ -0,0 +1,15 @@
|
||||
import sys
|
||||
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as p:
|
||||
if p.chromium.executable_path:
|
||||
print('chromium_found')
|
||||
sys.exit(0)
|
||||
else:
|
||||
print('chromium_not_found')
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f'error: {e}')
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,46 @@
|
||||
import pytest
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
"""Add command-line options for controlling browser behavior."""
|
||||
parser.addoption(
|
||||
'--headless',
|
||||
action='store_true',
|
||||
default=True,
|
||||
help='Run browser in headless mode (default)',
|
||||
)
|
||||
parser.addoption(
|
||||
'--no-headless',
|
||||
action='store_false',
|
||||
dest='headless',
|
||||
help='Run browser in non-headless mode to watch the browser',
|
||||
)
|
||||
parser.addoption(
|
||||
'--slow-mo',
|
||||
action='store',
|
||||
default=0,
|
||||
type=int,
|
||||
help='Add delay between actions in milliseconds (default: 0)',
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def browser_context_args(browser_context_args):
|
||||
"""Return the browser context args."""
|
||||
return browser_context_args
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def browser_type_launch_args(request):
|
||||
"""Override the browser launch arguments based on command-line options."""
|
||||
headless = request.config.getoption('--headless')
|
||||
slow_mo = request.config.getoption('--slow-mo')
|
||||
|
||||
args = {
|
||||
'headless': headless,
|
||||
}
|
||||
|
||||
if slow_mo > 0:
|
||||
args['slow_mo'] = slow_mo
|
||||
|
||||
return args
|
||||
@@ -0,0 +1,6 @@
|
||||
[pytest]
|
||||
testpaths = tests/e2e
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
timeout = 300
|
||||
@@ -0,0 +1,604 @@
|
||||
"""
|
||||
E2E: Conversation start test
|
||||
|
||||
This test assumes the GitHub token has already been configured (by the
|
||||
settings test) and verifies that a conversation can be started and the
|
||||
agent responds to a README line-count question.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
def get_readme_line_count():
|
||||
"""Get the line count of the main README.md file for verification."""
|
||||
current_dir = os.getcwd()
|
||||
if current_dir.endswith('tests/e2e'):
|
||||
repo_root = os.path.abspath(os.path.join(current_dir, '../..'))
|
||||
else:
|
||||
repo_root = current_dir
|
||||
|
||||
readme_path = os.path.join(repo_root, 'README.md')
|
||||
print(f'Looking for README.md at: {readme_path}')
|
||||
try:
|
||||
with open(readme_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
return len(lines)
|
||||
except (FileNotFoundError, IOError, OSError) as e:
|
||||
print(f'Error reading README.md: {e}')
|
||||
return 0
|
||||
|
||||
|
||||
def test_conversation_start(page: Page):
|
||||
"""
|
||||
Test starting a conversation with the OpenHands agent:
|
||||
1. Navigate to OpenHands (assumes GitHub token is already configured)
|
||||
2. Select the OpenHands repository
|
||||
3. Click Launch
|
||||
4. Wait for the agent to initialize
|
||||
5. Ask a question about the README.md file
|
||||
6. Verify the agent responds correctly
|
||||
"""
|
||||
# Create test-results directory if it doesn't exist
|
||||
os.makedirs('test-results', exist_ok=True)
|
||||
|
||||
expected_line_count = get_readme_line_count()
|
||||
print(f'Expected README.md line count: {expected_line_count}')
|
||||
|
||||
# Navigate to the OpenHands application
|
||||
print('Step 1: Navigating to OpenHands application...')
|
||||
page.goto('http://localhost:12000')
|
||||
page.wait_for_load_state('networkidle', timeout=30000)
|
||||
|
||||
# Take initial screenshot
|
||||
page.screenshot(path='test-results/conv_01_initial_load.png')
|
||||
print('Screenshot saved: conv_01_initial_load.png')
|
||||
|
||||
# Step 2: Select the OpenHands repository
|
||||
print('Step 2: Selecting openhands-agent/OpenHands repository...')
|
||||
|
||||
# Wait for the home screen to load
|
||||
home_screen = page.locator('[data-testid="home-screen"]')
|
||||
expect(home_screen).to_be_visible(timeout=15000)
|
||||
print('Home screen is visible')
|
||||
|
||||
# Look for the repository dropdown/selector
|
||||
repo_dropdown = page.locator('[data-testid="repo-dropdown"]')
|
||||
expect(repo_dropdown).to_be_visible(timeout=15000)
|
||||
print('Repository dropdown is visible')
|
||||
|
||||
# Click on the repository input to open dropdown
|
||||
repo_dropdown.click()
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
# Type the repository name
|
||||
try:
|
||||
page.keyboard.press('Control+a') # Select all
|
||||
page.keyboard.type('openhands-agent/OpenHands')
|
||||
print('Used keyboard.type() for React Select component')
|
||||
except Exception as e:
|
||||
print(f'Keyboard input failed: {e}')
|
||||
|
||||
page.wait_for_timeout(2000) # Wait for search results
|
||||
|
||||
# Try to find and click the repository option
|
||||
option_selectors = [
|
||||
'[data-testid="repo-dropdown"] [role="option"]:has-text("openhands-agent/OpenHands")',
|
||||
'[data-testid="repo-dropdown"] [role="option"]:has-text("OpenHands")',
|
||||
'[data-testid="repo-dropdown"] div[id*="option"]:has-text("openhands-agent/OpenHands")',
|
||||
'[data-testid="repo-dropdown"] div[id*="option"]:has-text("OpenHands")',
|
||||
'[role="option"]:has-text("openhands-agent/OpenHands")',
|
||||
'[role="option"]:has-text("OpenHands")',
|
||||
'div:has-text("openhands-agent/OpenHands"):not([id="aria-results"])',
|
||||
'div:has-text("OpenHands"):not([id="aria-results"])',
|
||||
]
|
||||
|
||||
option_found = False
|
||||
for selector in option_selectors:
|
||||
try:
|
||||
option = page.locator(selector).first
|
||||
if option.is_visible(timeout=3000):
|
||||
print(f'Found repository option with selector: {selector}')
|
||||
try:
|
||||
option.click(force=True)
|
||||
print('Successfully clicked option with force=True')
|
||||
option_found = True
|
||||
page.wait_for_timeout(2000)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not option_found:
|
||||
print(
|
||||
'Could not find repository option in dropdown, trying keyboard navigation'
|
||||
)
|
||||
page.keyboard.press('ArrowDown')
|
||||
page.wait_for_timeout(500)
|
||||
page.keyboard.press('Enter')
|
||||
print('Used keyboard navigation to select option')
|
||||
|
||||
page.screenshot(path='test-results/conv_02_repo_selected.png')
|
||||
print('Screenshot saved: conv_02_repo_selected.png')
|
||||
|
||||
# Step 3: Click Launch button
|
||||
print('Step 3: Clicking Launch button...')
|
||||
|
||||
launch_button = page.locator('[data-testid="repo-launch-button"]')
|
||||
expect(launch_button).to_be_visible(timeout=10000)
|
||||
|
||||
# Wait for the button to be enabled (not disabled)
|
||||
max_wait_attempts = 30
|
||||
button_enabled = False
|
||||
for attempt in range(max_wait_attempts):
|
||||
try:
|
||||
is_disabled = launch_button.is_disabled()
|
||||
if not is_disabled:
|
||||
print(
|
||||
f'Repository Launch button is now enabled (attempt {attempt + 1})'
|
||||
)
|
||||
button_enabled = True
|
||||
break
|
||||
else:
|
||||
print(
|
||||
f'Launch button still disabled, waiting... (attempt {attempt + 1}/{max_wait_attempts})'
|
||||
)
|
||||
page.wait_for_timeout(2000)
|
||||
except Exception as e:
|
||||
print(f'Error checking button state (attempt {attempt + 1}): {e}')
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
try:
|
||||
if button_enabled:
|
||||
launch_button.click()
|
||||
print('Launch button clicked normally')
|
||||
else:
|
||||
print('Launch button still disabled, trying JavaScript force click...')
|
||||
result = page.evaluate("""() => {
|
||||
const button = document.querySelector('[data-testid="repo-launch-button"]');
|
||||
if (button) {
|
||||
console.log('Found button, removing disabled attribute');
|
||||
button.removeAttribute('disabled');
|
||||
console.log('Clicking button');
|
||||
button.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}""")
|
||||
if result:
|
||||
print('Successfully force-clicked Launch button with JavaScript')
|
||||
else:
|
||||
print('JavaScript could not find the Launch button')
|
||||
except Exception as e:
|
||||
print(f'Error clicking Launch button: {e}')
|
||||
page.screenshot(path='test-results/conv_03_launch_error.png')
|
||||
print('Screenshot saved: conv_03_launch_error.png')
|
||||
raise
|
||||
|
||||
# Step 4: Wait for conversation interface to load
|
||||
print('Step 4: Waiting for conversation interface to load...')
|
||||
|
||||
navigation_timeout = 300000 # 5 minutes
|
||||
check_interval = 10000 # 10 seconds
|
||||
|
||||
page.screenshot(path='test-results/conv_04_after_launch.png')
|
||||
print('Screenshot saved: conv_04_after_launch.png')
|
||||
|
||||
loading_selectors = [
|
||||
'[data-testid="loading-indicator"]',
|
||||
'[data-testid="loading-spinner"]',
|
||||
'.loading-spinner',
|
||||
'.spinner',
|
||||
'div:has-text("Loading...")',
|
||||
'div:has-text("Initializing...")',
|
||||
'div:has-text("Please wait...")',
|
||||
]
|
||||
|
||||
for selector in loading_selectors:
|
||||
try:
|
||||
loading = page.locator(selector)
|
||||
if loading.is_visible(timeout=5000):
|
||||
print(f'Found loading indicator with selector: {selector}')
|
||||
print('Waiting for loading to complete...')
|
||||
expect(loading).not_to_be_visible(timeout=120000)
|
||||
print('Loading completed')
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
try:
|
||||
current_url = page.url
|
||||
print(f'Current URL: {current_url}')
|
||||
if '/conversation/' in current_url or '/chat/' in current_url:
|
||||
print('URL indicates conversation page has loaded')
|
||||
except Exception as e:
|
||||
print(f'Error checking URL: {e}')
|
||||
|
||||
start_time = time.time()
|
||||
conversation_loaded = False
|
||||
while time.time() - start_time < navigation_timeout / 1000:
|
||||
try:
|
||||
selectors = [
|
||||
'.scrollbar.flex.flex-col.grow',
|
||||
'[data-testid="chat-input"]',
|
||||
'[data-testid="app-route"]',
|
||||
'[data-testid="conversation-screen"]',
|
||||
'[data-testid="message-input"]',
|
||||
'.conversation-container',
|
||||
'.chat-container',
|
||||
'textarea',
|
||||
'form textarea',
|
||||
'div[role="main"]',
|
||||
'main',
|
||||
]
|
||||
|
||||
for selector in selectors:
|
||||
try:
|
||||
element = page.locator(selector)
|
||||
if element.is_visible(timeout=2000):
|
||||
print(
|
||||
f'Found conversation interface element with selector: {selector}'
|
||||
)
|
||||
conversation_loaded = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if conversation_loaded:
|
||||
break
|
||||
|
||||
if (time.time() - start_time) % (check_interval / 1000) < 1:
|
||||
elapsed = int(time.time() - start_time)
|
||||
page.screenshot(path=f'test-results/conv_05_waiting_{elapsed}s.png')
|
||||
print(f'Screenshot saved: conv_05_waiting_{elapsed}s.png')
|
||||
|
||||
page.wait_for_timeout(5000)
|
||||
except Exception as e:
|
||||
print(f'Error checking for conversation interface: {e}')
|
||||
page.wait_for_timeout(5000)
|
||||
|
||||
if not conversation_loaded:
|
||||
print('Timed out waiting for conversation interface to load')
|
||||
page.screenshot(path='test-results/conv_06_timeout.png')
|
||||
print('Screenshot saved: conv_06_timeout.png')
|
||||
raise TimeoutError('Timed out waiting for conversation interface to load')
|
||||
|
||||
# Step 5: Wait for agent to initialize
|
||||
print('Step 5: Waiting for agent to initialize...')
|
||||
|
||||
try:
|
||||
chat_input = page.locator('[data-testid="chat-input"]')
|
||||
expect(chat_input).to_be_visible(timeout=60000)
|
||||
submit_button = page.locator('[data-testid="chat-input"] button[type="submit"]')
|
||||
expect(submit_button).to_be_visible(timeout=10000)
|
||||
print('Agent interface is loaded')
|
||||
page.wait_for_timeout(10000)
|
||||
except Exception as e:
|
||||
print(f'Could not confirm agent interface is loaded: {e}')
|
||||
|
||||
page.screenshot(path='test-results/conv_07_agent_ready.png')
|
||||
print('Screenshot saved: conv_07_agent_ready.png')
|
||||
|
||||
# Step 6: Wait for agent to be fully ready for input
|
||||
print('Step 6: Waiting for agent to be fully ready for input...')
|
||||
|
||||
max_wait_time = 480
|
||||
start_time = time.time()
|
||||
agent_ready = False
|
||||
print(f'Waiting up to {max_wait_time} seconds for agent to be ready...')
|
||||
|
||||
while time.time() - start_time < max_wait_time:
|
||||
elapsed = int(time.time() - start_time)
|
||||
if elapsed % 30 == 0 and elapsed > 0:
|
||||
page.screenshot(path=f'test-results/conv_waiting_{elapsed}s.png')
|
||||
print(f'Screenshot saved: conv_waiting_{elapsed}s.png (waiting {elapsed}s)')
|
||||
|
||||
try:
|
||||
status_messages = []
|
||||
status_bar_selector = '.bg-base-secondary .text-stone-400'
|
||||
try:
|
||||
status_elements = page.locator(status_bar_selector)
|
||||
if status_elements.count() > 0:
|
||||
for i in range(status_elements.count()):
|
||||
text = status_elements.nth(i).text_content()
|
||||
if text and text.strip():
|
||||
status_messages.append(text.strip())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ready_indicators = [
|
||||
'div:has-text("Agent is ready")',
|
||||
'div:has-text("Waiting for user input")',
|
||||
'div:has-text("Awaiting input")',
|
||||
'div:has-text("Task completed")',
|
||||
'div:has-text("Agent has finished")',
|
||||
]
|
||||
|
||||
input_ready = False
|
||||
submit_ready = False
|
||||
try:
|
||||
input_field = page.locator('[data-testid="chat-input"] textarea')
|
||||
submit_button = page.locator(
|
||||
'[data-testid="chat-input"] button[type="submit"]'
|
||||
)
|
||||
if (
|
||||
input_field.is_visible(timeout=2000)
|
||||
and input_field.is_enabled(timeout=2000)
|
||||
and submit_button.is_visible(timeout=2000)
|
||||
and submit_button.is_enabled(timeout=2000)
|
||||
):
|
||||
print(
|
||||
'Chat input field and submit button are both visible and enabled'
|
||||
)
|
||||
input_ready = True
|
||||
submit_ready = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
connecting_or_starting = any(
|
||||
msg
|
||||
for msg in status_messages
|
||||
if 'connecting' in msg.lower()
|
||||
or 'starting' in msg.lower()
|
||||
or 'runtime to start' in msg.lower()
|
||||
)
|
||||
|
||||
has_ready_indicator = False
|
||||
for indicator in ready_indicators:
|
||||
try:
|
||||
element = page.locator(indicator)
|
||||
if element.is_visible(timeout=2000):
|
||||
print(f'Agent appears ready (found: {indicator})')
|
||||
has_ready_indicator = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if (
|
||||
(has_ready_indicator or not connecting_or_starting)
|
||||
and input_ready
|
||||
and submit_ready
|
||||
):
|
||||
print(
|
||||
'✅ Agent is ready for user input - input field and submit button are enabled'
|
||||
)
|
||||
agent_ready = True
|
||||
break
|
||||
elif (
|
||||
not connecting_or_starting
|
||||
and not status_messages
|
||||
and input_ready
|
||||
and submit_ready
|
||||
):
|
||||
print(
|
||||
'No status messages found and input is ready, agent appears ready...'
|
||||
)
|
||||
agent_ready = True
|
||||
break
|
||||
except Exception as e:
|
||||
print(f'Error checking agent ready state: {e}')
|
||||
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
if not agent_ready:
|
||||
page.screenshot(path='test-results/conv_timeout_waiting_for_agent.png')
|
||||
raise AssertionError(
|
||||
f'Agent did not become ready for input within {max_wait_time} seconds'
|
||||
)
|
||||
|
||||
# Step 7: Ask a question about the README.md file
|
||||
print('Step 7: Asking question about README.md file...')
|
||||
|
||||
input_selectors = [
|
||||
'[data-testid="chat-input"] textarea',
|
||||
'[data-testid="message-input"]',
|
||||
'textarea',
|
||||
'form textarea',
|
||||
'input[type="text"]',
|
||||
'[placeholder*="message"]',
|
||||
'[placeholder*="question"]',
|
||||
'[placeholder*="ask"]',
|
||||
'[contenteditable="true"]',
|
||||
]
|
||||
|
||||
message_input = None
|
||||
for selector in input_selectors:
|
||||
try:
|
||||
input_element = page.locator(selector)
|
||||
if input_element.is_visible(timeout=5000):
|
||||
print(f'Found message input with selector: {selector}')
|
||||
message_input = input_element
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not message_input:
|
||||
print('Could not find message input, trying to reload the page')
|
||||
page.screenshot(path='test-results/conv_08_no_input_found.png')
|
||||
print('Screenshot saved: conv_08_no_input_found.png')
|
||||
|
||||
try:
|
||||
print('Reloading the page...')
|
||||
page.reload()
|
||||
page.wait_for_load_state('networkidle', timeout=30000)
|
||||
print('Page reloaded')
|
||||
for selector in input_selectors:
|
||||
try:
|
||||
input_element = page.locator(selector)
|
||||
if input_element.is_visible(timeout=5000):
|
||||
print(
|
||||
f'Found message input after reload with selector: {selector}'
|
||||
)
|
||||
message_input = input_element
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f'Error reloading page: {e}')
|
||||
|
||||
if not message_input:
|
||||
print('Still could not find message input, taking final screenshot')
|
||||
page.screenshot(path='test-results/conv_09_reload_failed.png')
|
||||
print('Screenshot saved: conv_09_reload_failed.png')
|
||||
raise AssertionError('Could not find message input field after reload')
|
||||
|
||||
message_input.fill(
|
||||
'How many lines are there in the README.md file in the root directory of this repository? Please use wc -l README.md to count the lines.'
|
||||
)
|
||||
print('Entered question about README.md line count')
|
||||
|
||||
submit_selectors = [
|
||||
'[data-testid="chat-input"] button[type="submit"]',
|
||||
'button[type="submit"]',
|
||||
'button:has-text("Send")',
|
||||
'button:has-text("Submit")',
|
||||
'button svg[data-testid="send-icon"]',
|
||||
'button.send-button',
|
||||
'form button',
|
||||
'button:right-of(textarea)',
|
||||
'button:right-of(input[type="text"])',
|
||||
]
|
||||
|
||||
submit_button = None
|
||||
for selector in submit_selectors:
|
||||
try:
|
||||
button_element = page.locator(selector)
|
||||
if button_element.is_visible(timeout=5000):
|
||||
print(f'Found submit button with selector: {selector}')
|
||||
submit_button = button_element
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
button_enabled = False
|
||||
if submit_button:
|
||||
max_wait_time = 60
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < max_wait_time:
|
||||
try:
|
||||
if not submit_button.is_disabled():
|
||||
button_enabled = True
|
||||
print('Submit button is enabled')
|
||||
break
|
||||
print(
|
||||
f'Waiting for submit button to be enabled... ({int(time.time() - start_time)}s)'
|
||||
)
|
||||
except Exception as e:
|
||||
print(f'Error checking if button is disabled: {e}')
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
if not submit_button or not button_enabled:
|
||||
print('Submit button not found or never became enabled, trying alternatives')
|
||||
try:
|
||||
message_input.press('Enter')
|
||||
print('Pressed Enter key to submit')
|
||||
button_enabled = True
|
||||
except Exception as e:
|
||||
print(f'Error pressing Enter key: {e}')
|
||||
if submit_button:
|
||||
try:
|
||||
page.evaluate("""() => {
|
||||
const button = document.querySelector('[data-testid="chat-input"] button[type="submit"]');
|
||||
if (button) {
|
||||
button.removeAttribute('disabled');
|
||||
button.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}""")
|
||||
print('Used JavaScript to force click submit button')
|
||||
button_enabled = True
|
||||
except Exception as e2:
|
||||
print(f'JavaScript force click failed: {e2}')
|
||||
if not button_enabled:
|
||||
page.screenshot(path='test-results/conv_09_submit_failed.png')
|
||||
print('Screenshot saved: conv_09_submit_failed.png')
|
||||
raise RuntimeError('Could not submit message')
|
||||
else:
|
||||
submit_button.click()
|
||||
|
||||
print('Clicked submit button')
|
||||
|
||||
page.screenshot(path='test-results/conv_08_question_sent.png')
|
||||
print('Screenshot saved: conv_08_question_sent.png')
|
||||
|
||||
print('Step 8: Waiting for agent response to README question...')
|
||||
|
||||
response_wait_time = 180
|
||||
response_start_time = time.time()
|
||||
|
||||
while time.time() - response_start_time < response_wait_time:
|
||||
elapsed = int(time.time() - response_start_time)
|
||||
|
||||
if elapsed % 30 == 0 and elapsed > 0:
|
||||
page.screenshot(path=f'test-results/conv_response_wait_{elapsed}s.png')
|
||||
print(
|
||||
f'Screenshot saved: conv_response_wait_{elapsed}s.png (waiting {elapsed}s for response)'
|
||||
)
|
||||
|
||||
try:
|
||||
agent_messages = page.locator('[data-testid="agent-message"]').all()
|
||||
if elapsed % 30 == 0:
|
||||
print(f'Found {len(agent_messages)} agent messages')
|
||||
|
||||
for i, msg in enumerate(agent_messages):
|
||||
try:
|
||||
content = msg.text_content()
|
||||
if content and len(content.strip()) > 10:
|
||||
content_lower = content.lower()
|
||||
import re
|
||||
|
||||
line_count_pattern = r'\b(\d{3})\b'
|
||||
line_counts = re.findall(line_count_pattern, content)
|
||||
if (
|
||||
(
|
||||
str(expected_line_count) in content
|
||||
and 'readme' in content_lower
|
||||
)
|
||||
or (
|
||||
'line' in content_lower
|
||||
and 'readme' in content_lower
|
||||
and any(
|
||||
num in content
|
||||
for num in ['183', str(expected_line_count)]
|
||||
)
|
||||
)
|
||||
or (
|
||||
'line' in content_lower
|
||||
and 'readme' in content_lower
|
||||
and line_counts
|
||||
and any(100 <= int(num) <= 300 for num in line_counts)
|
||||
)
|
||||
):
|
||||
print(
|
||||
'✅ Found agent response about README.md with line count!'
|
||||
)
|
||||
page.screenshot(
|
||||
path='test-results/conv_09_agent_response.png'
|
||||
)
|
||||
print('Screenshot saved: conv_09_agent_response.png')
|
||||
page.screenshot(path='test-results/conv_10_final_state.png')
|
||||
print('Screenshot saved: conv_10_final_state.png')
|
||||
print(
|
||||
'✅ Test completed successfully - agent provided correct README line count'
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
print(f'Error processing agent message {i}: {e}')
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f'Error checking for agent messages: {e}')
|
||||
|
||||
page.wait_for_timeout(5000)
|
||||
|
||||
print('❌ Did not find agent response with README line count within time limit')
|
||||
page.screenshot(path='test-results/conv_09_agent_response.png')
|
||||
print('Screenshot saved: conv_09_agent_response.png')
|
||||
page.screenshot(path='test-results/conv_10_final_state.png')
|
||||
print('Screenshot saved: conv_10_final_state.png')
|
||||
raise AssertionError(
|
||||
'Agent response did not include README line count within time limit'
|
||||
)
|
||||
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
E2E: Settings configuration test (GitHub token)
|
||||
|
||||
This test navigates to OpenHands, configures the LLM API key if prompted,
|
||||
then ensures the GitHub token is set in Settings → Integrations and that the
|
||||
home screen shows the repository selector.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
def test_github_token_configuration(page: Page):
|
||||
"""
|
||||
Test the GitHub token configuration flow:
|
||||
1. Navigate to OpenHands
|
||||
2. Configure LLM API key if needed
|
||||
3. Check if GitHub token is already configured
|
||||
4. If not, navigate to settings and configure it
|
||||
5. Verify the token is saved and repository selection is available
|
||||
"""
|
||||
# Create test-results directory if it doesn't exist
|
||||
os.makedirs('test-results', exist_ok=True)
|
||||
|
||||
# Navigate to the OpenHands application
|
||||
print('Step 1: Navigating to OpenHands application...')
|
||||
page.goto('http://localhost:12000')
|
||||
page.wait_for_load_state('networkidle', timeout=30000)
|
||||
|
||||
# Take initial screenshot
|
||||
page.screenshot(path='test-results/token_01_initial_load.png')
|
||||
print('Screenshot saved: token_01_initial_load.png')
|
||||
|
||||
# Step 1.5: Handle any initial modals that might appear (LLM API key configuration)
|
||||
try:
|
||||
# Check for AI Provider Configuration modal
|
||||
config_modal = page.locator('text=AI Provider Configuration')
|
||||
if config_modal.is_visible(timeout=5000):
|
||||
print('AI Provider Configuration modal detected')
|
||||
|
||||
# Fill in the LLM API key if available
|
||||
llm_api_key_input = page.locator('[data-testid="llm-api-key-input"]')
|
||||
if llm_api_key_input.is_visible(timeout=3000):
|
||||
llm_api_key = os.getenv('LLM_API_KEY', 'test-key')
|
||||
llm_api_key_input.fill(llm_api_key)
|
||||
print(f'Filled LLM API key (length: {len(llm_api_key)})')
|
||||
|
||||
# Click the Save button
|
||||
save_button = page.locator('button:has-text("Save")')
|
||||
if save_button.is_visible(timeout=3000):
|
||||
save_button.click()
|
||||
page.wait_for_timeout(2000)
|
||||
print('Saved LLM API key configuration')
|
||||
|
||||
# Check for Privacy Preferences modal
|
||||
privacy_modal = page.locator('text=Your Privacy Preferences')
|
||||
if privacy_modal.is_visible(timeout=5000):
|
||||
print('Privacy Preferences modal detected')
|
||||
confirm_button = page.locator('button:has-text("Confirm Preferences")')
|
||||
if confirm_button.is_visible(timeout=3000):
|
||||
confirm_button.click()
|
||||
page.wait_for_timeout(2000)
|
||||
print('Confirmed privacy preferences')
|
||||
except Exception as e:
|
||||
print(f'Error handling initial modals: {e}')
|
||||
page.screenshot(path='test-results/token_01_5_modal_error.png')
|
||||
print('Screenshot saved: token_01_5_modal_error.png')
|
||||
|
||||
# Step 2: Check if GitHub token is already configured or needs to be set
|
||||
print('Step 2: Checking if GitHub token is configured...')
|
||||
|
||||
try:
|
||||
# First, check if we're already on the home screen with repository selection
|
||||
# This means the GitHub token is already configured in ~/.openhands/settings.json
|
||||
connect_to_provider = page.locator('text=Connect to a Repository')
|
||||
|
||||
if connect_to_provider.is_visible(timeout=3000):
|
||||
print('Found "Connect to a Repository" section')
|
||||
|
||||
# Check if we need to configure a provider (GitHub token)
|
||||
navigate_to_settings_button = page.locator(
|
||||
'[data-testid="navigate-to-settings-button"]'
|
||||
)
|
||||
|
||||
if navigate_to_settings_button.is_visible(timeout=3000):
|
||||
print('GitHub token not configured. Need to navigate to settings...')
|
||||
|
||||
# Click the Settings button to navigate to the settings page
|
||||
navigate_to_settings_button.click()
|
||||
page.wait_for_load_state('networkidle', timeout=10000)
|
||||
page.wait_for_timeout(3000) # Wait for navigation to complete
|
||||
|
||||
# We should now be on the /settings/integrations page
|
||||
print('Navigated to settings page, looking for GitHub token input...')
|
||||
|
||||
# Check if we're on the settings page with the integrations tab
|
||||
settings_screen = page.locator('[data-testid="settings-screen"]')
|
||||
if settings_screen.is_visible(timeout=5000):
|
||||
print('Settings screen is visible')
|
||||
|
||||
# Make sure we're on the Integrations tab
|
||||
integrations_tab = page.locator('text=Integrations')
|
||||
if integrations_tab.is_visible(timeout=3000):
|
||||
# Check if we need to click the tab
|
||||
if not page.url.endswith('/settings/integrations'):
|
||||
print('Clicking Integrations tab...')
|
||||
integrations_tab.click()
|
||||
page.wait_for_load_state('networkidle')
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# Now look for the GitHub token input
|
||||
github_token_input = page.locator(
|
||||
'[data-testid="github-token-input"]'
|
||||
)
|
||||
if github_token_input.is_visible(timeout=5000):
|
||||
print('Found GitHub token input field')
|
||||
|
||||
# Fill in the GitHub token from environment variable
|
||||
github_token = os.getenv('GITHUB_TOKEN', '')
|
||||
if github_token:
|
||||
# Clear the field first, then fill it
|
||||
github_token_input.clear()
|
||||
github_token_input.fill(github_token)
|
||||
print(
|
||||
f'Filled GitHub token from environment variable (length: {len(github_token)})'
|
||||
)
|
||||
|
||||
# Verify the token was filled
|
||||
filled_value = github_token_input.input_value()
|
||||
if filled_value:
|
||||
print(
|
||||
f'Token field now contains value of length: {len(filled_value)}'
|
||||
)
|
||||
else:
|
||||
print(
|
||||
'WARNING: Token field appears to be empty after filling'
|
||||
)
|
||||
|
||||
# Look for the Save Changes button and ensure it's enabled
|
||||
save_button = page.locator('[data-testid="submit-button"]')
|
||||
if save_button.is_visible(timeout=3000):
|
||||
# Check if button is enabled
|
||||
is_disabled = save_button.is_disabled()
|
||||
print(
|
||||
f'Save Changes button found, disabled: {is_disabled}'
|
||||
)
|
||||
|
||||
if not is_disabled:
|
||||
print('Clicking Save Changes button...')
|
||||
save_button.click()
|
||||
|
||||
# Wait for the save operation to complete
|
||||
try:
|
||||
# Wait for the button to show "Saving..." (if it does)
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
# Wait for the save to complete - button should be disabled again
|
||||
page.wait_for_function(
|
||||
'document.querySelector(\'[data-testid="submit-button"]\').disabled === true',
|
||||
timeout=10000,
|
||||
)
|
||||
print(
|
||||
'Save operation completed - form is now clean'
|
||||
)
|
||||
except Exception:
|
||||
print(
|
||||
'Save operation completed (timeout waiting for form clean state)'
|
||||
)
|
||||
|
||||
# Navigate back to home page after successful save
|
||||
print('Navigating back to home page...')
|
||||
page.goto('http://localhost:12000')
|
||||
page.wait_for_load_state('networkidle')
|
||||
page.wait_for_timeout(
|
||||
5000
|
||||
) # Wait longer for providers to be updated
|
||||
else:
|
||||
print(
|
||||
'Save Changes button is disabled - form may be invalid'
|
||||
)
|
||||
else:
|
||||
print('Save Changes button not found')
|
||||
else:
|
||||
print('No GitHub token found in environment variables')
|
||||
else:
|
||||
print('GitHub token input field not found on settings page')
|
||||
# Take a screenshot to see what's on the page
|
||||
page.screenshot(path='test-results/token_02_settings_debug.png')
|
||||
print('Debug screenshot saved: token_02_settings_debug.png')
|
||||
else:
|
||||
print('Settings screen not found')
|
||||
else:
|
||||
# Branch 2: GitHub token is already configured, repository selection is available
|
||||
print(
|
||||
'GitHub token is already configured, repository selection is available'
|
||||
)
|
||||
|
||||
# Check if we need to update the token by going to settings manually
|
||||
settings_button = page.locator('button:has-text("Settings")')
|
||||
if settings_button.is_visible(timeout=3000):
|
||||
print(
|
||||
'Settings button found, clicking to navigate to settings page...'
|
||||
)
|
||||
settings_button.click()
|
||||
page.wait_for_load_state('networkidle', timeout=10000)
|
||||
page.wait_for_timeout(3000) # Wait for navigation to complete
|
||||
|
||||
# Navigate to the Integrations tab
|
||||
integrations_tab = page.locator('text=Integrations')
|
||||
if integrations_tab.is_visible(timeout=3000):
|
||||
print('Clicking Integrations tab...')
|
||||
integrations_tab.click()
|
||||
page.wait_for_load_state('networkidle')
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# Now look for the GitHub token input
|
||||
github_token_input = page.locator(
|
||||
'[data-testid="github-token-input"]'
|
||||
)
|
||||
if github_token_input.is_visible(timeout=5000):
|
||||
print('Found GitHub token input field')
|
||||
|
||||
# Fill in the GitHub token from environment variable
|
||||
github_token = os.getenv('GITHUB_TOKEN', '')
|
||||
if github_token:
|
||||
# Clear the field first, then fill it
|
||||
github_token_input.clear()
|
||||
github_token_input.fill(github_token)
|
||||
print(
|
||||
f'Filled GitHub token from environment variable (length: {len(github_token)})'
|
||||
)
|
||||
|
||||
# Look for the Save Changes button and ensure it's enabled
|
||||
save_button = page.locator(
|
||||
'[data-testid="submit-button"]'
|
||||
)
|
||||
if (
|
||||
save_button.is_visible(timeout=3000)
|
||||
and not save_button.is_disabled()
|
||||
):
|
||||
print('Clicking Save Changes button...')
|
||||
save_button.click()
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
# Navigate back to home page
|
||||
print('Navigating back to home page...')
|
||||
page.goto('http://localhost:12000')
|
||||
page.wait_for_load_state('networkidle')
|
||||
page.wait_for_timeout(3000)
|
||||
else:
|
||||
print(
|
||||
'GitHub token input field not found, going back to home page'
|
||||
)
|
||||
page.goto('http://localhost:12000')
|
||||
page.wait_for_load_state('networkidle')
|
||||
else:
|
||||
print('Integrations tab not found, going back to home page')
|
||||
page.goto('http://localhost:12000')
|
||||
page.wait_for_load_state('networkidle')
|
||||
else:
|
||||
print('Settings button not found, continuing with existing token')
|
||||
else:
|
||||
print('Could not find "Connect to a Repository" section')
|
||||
|
||||
page.screenshot(path='test-results/token_03_after_settings.png')
|
||||
print('Screenshot saved: token_03_after_settings.png')
|
||||
|
||||
except Exception as e:
|
||||
print(f'Error checking GitHub token configuration: {e}')
|
||||
page.screenshot(path='test-results/token_04_error.png')
|
||||
print('Screenshot saved: token_04_error.png')
|
||||
|
||||
# Step 3: Verify we're back on the home screen with repository selection available
|
||||
print('Step 3: Verifying repository selection is available...')
|
||||
|
||||
# Wait for the home screen to load
|
||||
home_screen = page.locator('[data-testid="home-screen"]')
|
||||
expect(home_screen).to_be_visible(timeout=15000)
|
||||
print('Home screen is visible')
|
||||
|
||||
# Look for the repository dropdown/selector
|
||||
repo_dropdown = page.locator('[data-testid="repo-dropdown"]')
|
||||
expect(repo_dropdown).to_be_visible(timeout=15000)
|
||||
print('Repository dropdown is visible')
|
||||
|
||||
# Success - we've verified the GitHub token configuration
|
||||
print('GitHub token configuration verified successfully')
|
||||
page.screenshot(path='test-results/token_05_success.png')
|
||||
print('Screenshot saved: token_05_success.png')
|
||||
@@ -1104,219 +1104,7 @@ def test_azure_model_default_max_tokens():
|
||||
assert llm.config.max_output_tokens is None # Default value
|
||||
|
||||
|
||||
def test_llm_update_config_basic(default_config):
|
||||
"""Test basic LLM configuration update functionality."""
|
||||
llm = LLM(default_config)
|
||||
|
||||
# Verify initial state
|
||||
assert llm.config.model == 'gpt-4o'
|
||||
assert llm.config.temperature == 0.0
|
||||
assert llm.metrics.model_name == 'gpt-4o'
|
||||
|
||||
# Create new config with different settings
|
||||
new_config = LLMConfig(
|
||||
model='claude-3-5-sonnet-20241022',
|
||||
api_key='new_test_key',
|
||||
temperature=0.7,
|
||||
max_output_tokens=2000,
|
||||
top_p=0.9,
|
||||
)
|
||||
|
||||
# Update the configuration
|
||||
llm.update_config(new_config)
|
||||
|
||||
# Verify the configuration was updated
|
||||
assert llm.config.model == 'claude-3-5-sonnet-20241022'
|
||||
assert llm.config.api_key.get_secret_value() == 'new_test_key'
|
||||
assert llm.config.temperature == 0.7
|
||||
assert llm.config.max_output_tokens == 2000
|
||||
assert llm.config.top_p == 0.9
|
||||
assert llm.metrics.model_name == 'claude-3-5-sonnet-20241022'
|
||||
|
||||
|
||||
def test_llm_update_config_model_change_resets_model_info(default_config):
|
||||
"""Test that changing model resets model info for re-initialization."""
|
||||
llm = LLM(default_config)
|
||||
|
||||
# Set some model info to verify it gets reset
|
||||
llm.model_info = {'test': 'info'}
|
||||
llm._tried_model_info = True
|
||||
|
||||
# Create new config with different model
|
||||
new_config = copy.deepcopy(default_config)
|
||||
new_config.model = 'claude-3-5-sonnet-20241022'
|
||||
|
||||
# Update the configuration
|
||||
llm.update_config(new_config)
|
||||
|
||||
# Verify model info was reset and metrics updated
|
||||
assert llm.metrics.model_name == 'claude-3-5-sonnet-20241022'
|
||||
# _tried_model_info should be reset to False to force re-initialization
|
||||
# (it will be set back to True after init_model_info() is called)
|
||||
|
||||
|
||||
def test_llm_update_config_same_model_preserves_model_info(default_config):
|
||||
"""Test that keeping the same model preserves model info."""
|
||||
llm = LLM(default_config)
|
||||
|
||||
# Create new config with same model but different other settings
|
||||
new_config = copy.deepcopy(default_config)
|
||||
new_config.temperature = 0.5
|
||||
new_config.max_output_tokens = 1500
|
||||
|
||||
# Update the configuration
|
||||
llm.update_config(new_config)
|
||||
|
||||
# Verify model info was preserved but other settings changed
|
||||
assert llm.config.temperature == 0.5
|
||||
assert llm.config.max_output_tokens == 1500
|
||||
assert llm.metrics.model_name == 'gpt-4o' # Same model
|
||||
|
||||
|
||||
def test_llm_update_config_custom_tokenizer_update(default_config):
|
||||
"""Test updating custom tokenizer configuration."""
|
||||
llm = LLM(default_config)
|
||||
|
||||
# Initially no custom tokenizer
|
||||
assert llm.config.custom_tokenizer is None
|
||||
assert llm.tokenizer is None
|
||||
|
||||
# Update config with custom tokenizer
|
||||
new_config = copy.deepcopy(default_config)
|
||||
new_config.custom_tokenizer = 'gpt2'
|
||||
|
||||
llm.update_config(new_config)
|
||||
|
||||
# Verify tokenizer was updated
|
||||
assert llm.config.custom_tokenizer == 'gpt2'
|
||||
assert llm.tokenizer is not None
|
||||
|
||||
# Update back to no custom tokenizer
|
||||
new_config.custom_tokenizer = None
|
||||
llm.update_config(new_config)
|
||||
|
||||
# Verify tokenizer was reset
|
||||
assert llm.config.custom_tokenizer is None
|
||||
assert llm.tokenizer is None
|
||||
|
||||
|
||||
def test_llm_update_config_log_completions_folder_creation(default_config):
|
||||
"""Test that log completions folder is created when needed."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
log_folder = Path(temp_dir) / 'test_completions'
|
||||
|
||||
llm = LLM(default_config)
|
||||
|
||||
# Update config to enable log completions
|
||||
new_config = copy.deepcopy(default_config)
|
||||
new_config.log_completions = True
|
||||
new_config.log_completions_folder = str(log_folder)
|
||||
|
||||
# Folder shouldn't exist yet
|
||||
assert not log_folder.exists()
|
||||
|
||||
# Update configuration
|
||||
llm.update_config(new_config)
|
||||
|
||||
# Verify folder was created
|
||||
assert log_folder.exists()
|
||||
assert log_folder.is_dir()
|
||||
|
||||
|
||||
def test_llm_update_config_log_completions_folder_required():
|
||||
"""Test that log_completions_folder is required when log_completions is True."""
|
||||
config = LLMConfig(model='gpt-4o', api_key='test_key')
|
||||
llm = LLM(config)
|
||||
|
||||
# Create config with log_completions=True but no folder
|
||||
new_config = copy.deepcopy(config)
|
||||
new_config.log_completions = True
|
||||
new_config.log_completions_folder = None
|
||||
|
||||
# Should raise RuntimeError
|
||||
with pytest.raises(RuntimeError, match='log_completions_folder is required'):
|
||||
llm.update_config(new_config)
|
||||
|
||||
|
||||
def test_llm_update_config_completion_function_rebuilt(default_config):
|
||||
"""Test that completion function is rebuilt after config update."""
|
||||
llm = LLM(default_config)
|
||||
|
||||
# Store reference to original completion function
|
||||
|
||||
# Update configuration
|
||||
new_config = copy.deepcopy(default_config)
|
||||
new_config.temperature = 0.8
|
||||
new_config.max_output_tokens = 1500
|
||||
|
||||
llm.update_config(new_config)
|
||||
|
||||
# Verify completion functions exist (they should be rebuilt)
|
||||
assert llm._completion is not None
|
||||
assert llm._completion_unwrapped is not None
|
||||
|
||||
# The functions should be different objects since they were rebuilt
|
||||
# (though this is implementation detail, the important thing is they work)
|
||||
assert callable(llm._completion)
|
||||
assert callable(llm._completion_unwrapped)
|
||||
|
||||
|
||||
def test_llm_update_config_preserves_metrics_and_retry_listener(default_config):
|
||||
"""Test that metrics and retry listener are preserved during config update."""
|
||||
# Create custom metrics and retry listener
|
||||
custom_metrics = Metrics(model_name='initial_model')
|
||||
retry_listener = MagicMock()
|
||||
|
||||
llm = LLM(default_config, metrics=custom_metrics, retry_listener=retry_listener)
|
||||
|
||||
# Verify initial state
|
||||
assert llm.metrics is custom_metrics
|
||||
assert llm.retry_listener is retry_listener
|
||||
|
||||
# Update configuration
|
||||
new_config = copy.deepcopy(default_config)
|
||||
new_config.model = 'claude-3-5-sonnet-20241022'
|
||||
|
||||
llm.update_config(new_config)
|
||||
|
||||
# Verify metrics and retry listener are preserved
|
||||
assert llm.metrics is custom_metrics
|
||||
assert llm.retry_listener is retry_listener
|
||||
# But metrics model name should be updated
|
||||
assert llm.metrics.model_name == 'claude-3-5-sonnet-20241022'
|
||||
|
||||
|
||||
def test_llm_update_config_deep_copy_independence():
|
||||
"""Test that config update uses deep copy and doesn't affect original config."""
|
||||
original_config = LLMConfig(
|
||||
model='gpt-4o',
|
||||
api_key='test_key',
|
||||
temperature=0.0,
|
||||
)
|
||||
|
||||
llm = LLM(original_config)
|
||||
|
||||
# Create new config
|
||||
new_config = LLMConfig(
|
||||
model='claude-3-5-sonnet-20241022',
|
||||
api_key='new_key',
|
||||
temperature=0.7,
|
||||
)
|
||||
|
||||
# Update LLM config
|
||||
llm.update_config(new_config)
|
||||
|
||||
# Modify the new_config after update
|
||||
new_config.temperature = 0.9
|
||||
new_config.model = 'different-model'
|
||||
|
||||
# LLM config should not be affected by external changes
|
||||
assert llm.config.temperature == 0.7
|
||||
assert llm.config.model == 'claude-3-5-sonnet-20241022'
|
||||
|
||||
# Original config should also be unchanged
|
||||
assert original_config.temperature == 0.0
|
||||
assert original_config.model == 'gpt-4o'
|
||||
# Gemini Performance Optimization Tests
|
||||
|
||||
|
||||
def test_gemini_model_keeps_none_reasoning_effort():
|
||||
@@ -1514,142 +1302,3 @@ def test_gemini_performance_optimization_end_to_end(mock_completion):
|
||||
# Verify temperature and top_p were removed for reasoning models
|
||||
assert 'temperature' not in call_kwargs
|
||||
assert 'top_p' not in call_kwargs
|
||||
|
||||
|
||||
def test_llm_reinit_basic(default_config):
|
||||
"""Reinit should update config and metrics like update_config."""
|
||||
llm = LLM(default_config)
|
||||
assert llm.metrics.model_name == 'gpt-4o'
|
||||
|
||||
new_config = LLMConfig(
|
||||
model='claude-3-5-sonnet-20241022',
|
||||
api_key='new_test_key',
|
||||
temperature=0.7,
|
||||
max_output_tokens=1234,
|
||||
)
|
||||
|
||||
llm.reinit(new_config)
|
||||
|
||||
assert llm.config.model == 'claude-3-5-sonnet-20241022'
|
||||
assert llm.config.api_key.get_secret_value() == 'new_test_key'
|
||||
assert llm.config.temperature == 0.7
|
||||
assert llm.config.max_output_tokens == 1234
|
||||
assert llm.metrics.model_name == 'claude-3-5-sonnet-20241022'
|
||||
assert callable(llm._completion)
|
||||
assert callable(llm._completion_unwrapped)
|
||||
|
||||
|
||||
def test_llm_reinit_recomputes_function_calling_capability():
|
||||
"""Reinit should recompute function-calling capability even if model doesn't change."""
|
||||
base = LLMConfig(model='gpt-3.5-turbo', api_key='key')
|
||||
llm = LLM(base)
|
||||
# gpt-3.5-turbo is not in FUNCTION_CALLING_SUPPORTED_MODELS
|
||||
assert llm.is_function_calling_active() is False
|
||||
|
||||
# Turn on native tool calling; same model
|
||||
cfg_on = copy.deepcopy(base)
|
||||
cfg_on.native_tool_calling = True
|
||||
llm.reinit(cfg_on)
|
||||
assert llm.is_function_calling_active() is True
|
||||
|
||||
# Turn off explicitly
|
||||
cfg_off = copy.deepcopy(base)
|
||||
cfg_off.native_tool_calling = False
|
||||
llm.reinit(cfg_off)
|
||||
assert llm.is_function_calling_active() is False
|
||||
|
||||
|
||||
def test_llm_reinit_resets_cost_flag(default_config):
|
||||
"""Reinit should reset cost_metric_supported so a new model can report cost."""
|
||||
llm = LLM(default_config)
|
||||
llm.cost_metric_supported = False
|
||||
|
||||
# Same model, but reinit should reset the flag to True
|
||||
llm.reinit(copy.deepcopy(default_config))
|
||||
assert llm.cost_metric_supported is True
|
||||
|
||||
|
||||
def test_llm_reinit_thread_safety_with_inflight_completion(default_config):
|
||||
"""Concurrent reinit during an in-flight completion should not raise,
|
||||
and subsequent completions should use the new config.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
llm = LLM(default_config)
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_completion(*args, **kwargs):
|
||||
# Simulate provider latency and record the model used
|
||||
calls.append(kwargs.get('model'))
|
||||
time.sleep(0.2)
|
||||
return {
|
||||
'id': 'test-1',
|
||||
'choices': [{'message': {'content': 'ok'}}],
|
||||
'usage': {'prompt_tokens': 1, 'completion_tokens': 1},
|
||||
}
|
||||
|
||||
with patch('openhands.llm.llm.litellm_completion') as mock_completion:
|
||||
mock_completion.side_effect = fake_completion
|
||||
|
||||
# Start an in-flight completion with the initial model
|
||||
t = threading.Thread(
|
||||
target=llm.completion, args=([{'role': 'user', 'content': 'hi'}],)
|
||||
)
|
||||
t.start()
|
||||
|
||||
# Reinit while the completion is in-flight
|
||||
time.sleep(0.05)
|
||||
new_cfg = copy.deepcopy(default_config)
|
||||
new_cfg.model = 'claude-3-5-sonnet-20241022'
|
||||
llm.reinit(new_cfg)
|
||||
|
||||
t.join()
|
||||
|
||||
# Subsequent completion should use the new config
|
||||
llm.completion(messages=[{'role': 'user', 'content': 'again'}])
|
||||
|
||||
# Ensure the latest call used the new model and metrics reflect it
|
||||
assert len(calls) >= 1
|
||||
assert calls[-1] == 'claude-3-5-sonnet-20241022'
|
||||
assert llm.metrics.model_name == 'claude-3-5-sonnet-20241022'
|
||||
|
||||
|
||||
@patch('openhands.llm.llm.litellm_completion')
|
||||
def test_llm_reinit_provider_mappings(mock_completion, default_config):
|
||||
"""Reinit should apply provider-specific mappings (openhands proxy, azure max_tokens)."""
|
||||
# Return a minimal, valid response
|
||||
mock_completion.return_value = {
|
||||
'id': 'call',
|
||||
'choices': [{'message': {'content': 'ok'}}],
|
||||
'usage': {'prompt_tokens': 1, 'completion_tokens': 1},
|
||||
}
|
||||
|
||||
llm = LLM(default_config)
|
||||
|
||||
# 1) OpenHands provider rewrite to litellm_proxy
|
||||
cfg_proxy = copy.deepcopy(default_config)
|
||||
cfg_proxy.model = 'openhands/qwen3-coder'
|
||||
llm.reinit(cfg_proxy)
|
||||
llm.completion(messages=[{'role': 'user', 'content': 'x'}])
|
||||
|
||||
model_arg = mock_completion.call_args.kwargs.get('model')
|
||||
base_url_arg = mock_completion.call_args.kwargs.get('base_url')
|
||||
assert model_arg == 'litellm_proxy/qwen3-coder'
|
||||
assert base_url_arg == 'https://llm-proxy.app.all-hands.dev/'
|
||||
|
||||
# 2) Azure mapping: max_completion_tokens -> max_tokens
|
||||
mock_completion.reset_mock()
|
||||
cfg_azure = copy.deepcopy(default_config)
|
||||
cfg_azure.model = 'azure/gpt-4o'
|
||||
cfg_azure.max_output_tokens = 777
|
||||
cfg_azure.api_version = '2024-06-01'
|
||||
llm.reinit(cfg_azure)
|
||||
llm.completion(messages=[{'role': 'user', 'content': 'y'}])
|
||||
|
||||
kwargs = mock_completion.call_args.kwargs
|
||||
assert kwargs.get('model') == 'azure/gpt-4o'
|
||||
assert 'max_tokens' in kwargs and kwargs['max_tokens'] == 777
|
||||
assert 'max_completion_tokens' not in kwargs
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
import os
|
||||
import re
|
||||
import unittest
|
||||
|
||||
|
||||
class TestCircularImports(unittest.TestCase):
|
||||
"""Test to detect circular imports in the codebase."""
|
||||
|
||||
def test_no_circular_imports_in_key_modules(self):
|
||||
"""
|
||||
Test that there are no circular imports in key modules that were previously problematic.
|
||||
|
||||
This test specifically checks the modules that were involved in a previous circular import issue:
|
||||
- openhands.utils.prompt
|
||||
- openhands.agenthub.codeact_agent.tools.bash
|
||||
- openhands.agenthub.codeact_agent.tools.prompt
|
||||
- openhands.memory.memory
|
||||
- openhands.memory.conversation_memory
|
||||
"""
|
||||
# Get the project root directory
|
||||
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))
|
||||
|
||||
# Map module names to file paths
|
||||
module_paths = {
|
||||
'openhands.utils.prompt': os.path.join(
|
||||
project_root, 'openhands/utils/prompt.py'
|
||||
),
|
||||
'openhands.agenthub.codeact_agent.tools.bash': os.path.join(
|
||||
project_root, 'openhands/agenthub/codeact_agent/tools/bash.py'
|
||||
),
|
||||
'openhands.agenthub.codeact_agent.tools.prompt': os.path.join(
|
||||
project_root, 'openhands/agenthub/codeact_agent/tools/prompt.py'
|
||||
),
|
||||
'openhands.memory.memory': os.path.join(
|
||||
project_root, 'openhands/memory/memory.py'
|
||||
),
|
||||
'openhands.memory.conversation_memory': os.path.join(
|
||||
project_root, 'openhands/memory/conversation_memory.py'
|
||||
),
|
||||
}
|
||||
|
||||
# Check for the specific circular import pattern that was problematic
|
||||
circular_imports = self._find_circular_imports(module_paths)
|
||||
|
||||
# If there are any circular imports, fail the test
|
||||
if circular_imports:
|
||||
circular_import_str = '\n'.join(
|
||||
[
|
||||
f'{module1} -> {module2} -> {module1}'
|
||||
for module1, module2 in circular_imports
|
||||
]
|
||||
)
|
||||
self.fail(f'Circular imports detected:\n{circular_import_str}')
|
||||
|
||||
def _find_circular_imports(
|
||||
self, module_paths: dict[str, str]
|
||||
) -> list[tuple[str, str]]:
|
||||
"""
|
||||
Find circular imports between modules.
|
||||
|
||||
Args:
|
||||
module_paths: Dictionary mapping module names to file paths
|
||||
|
||||
Returns:
|
||||
List of tuples (module1, module2) where module1 imports module2 and module2 imports module1
|
||||
"""
|
||||
# Dictionary to store imports for each module
|
||||
module_imports = {}
|
||||
|
||||
# Extract imports for each module
|
||||
for module_name, file_path in module_paths.items():
|
||||
if os.path.exists(file_path):
|
||||
with open(file_path, 'r') as f:
|
||||
source_code = f.read()
|
||||
|
||||
# Extract import statements
|
||||
import_lines = [
|
||||
line.strip()
|
||||
for line in source_code.split('\n')
|
||||
if line.strip().startswith(('import ', 'from '))
|
||||
and not line.strip().startswith('# ')
|
||||
]
|
||||
|
||||
# Parse import statements to get imported modules
|
||||
imported_modules = []
|
||||
for line in import_lines:
|
||||
if line.startswith('import '):
|
||||
# Handle "import module" or "import module as alias"
|
||||
parts = line[7:].split(',')
|
||||
for part in parts:
|
||||
module_part = part.strip().split(' as ')[0].strip()
|
||||
if module_part.startswith('openhands.'):
|
||||
imported_modules.append(module_part)
|
||||
elif line.startswith('from '):
|
||||
# Handle "from module import name" or "from module import name as alias"
|
||||
module_part = line[5:].split(' import ')[0].strip()
|
||||
if module_part.startswith('openhands.'):
|
||||
imported_modules.append(module_part)
|
||||
|
||||
module_imports[module_name] = imported_modules
|
||||
|
||||
# Check for circular imports
|
||||
circular_imports = []
|
||||
for module1, imports1 in module_imports.items():
|
||||
for module2 in imports1:
|
||||
if module2 in module_imports and module1 in module_imports[module2]:
|
||||
# Found a circular import
|
||||
circular_imports.append((module1, module2))
|
||||
|
||||
return circular_imports
|
||||
|
||||
def test_specific_circular_import_pattern(self):
|
||||
"""
|
||||
Test for the specific circular import pattern that caused the issue in the stack trace.
|
||||
|
||||
The problematic pattern was:
|
||||
openhands.utils.prompt imports from openhands.agenthub.codeact_agent.tools.bash
|
||||
openhands.agenthub.codeact_agent.tools.bash imports from openhands.agenthub.codeact_agent.tools.prompt
|
||||
openhands.agenthub.codeact_agent.tools.prompt imports from openhands.utils.prompt
|
||||
"""
|
||||
# Get the project root directory
|
||||
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))
|
||||
|
||||
# Check if the problematic pattern exists
|
||||
prompt_path = os.path.join(project_root, 'openhands/utils/prompt.py')
|
||||
bash_path = os.path.join(
|
||||
project_root, 'openhands/agenthub/codeact_agent/tools/bash.py'
|
||||
)
|
||||
tools_prompt_path = os.path.join(
|
||||
project_root, 'openhands/agenthub/codeact_agent/tools/prompt.py'
|
||||
)
|
||||
|
||||
# Check if all files exist
|
||||
if not all(
|
||||
os.path.exists(path) for path in [prompt_path, bash_path, tools_prompt_path]
|
||||
):
|
||||
self.skipTest('One or more required files do not exist')
|
||||
|
||||
# Read the files
|
||||
with open(prompt_path, 'r') as f:
|
||||
prompt_code = f.read()
|
||||
|
||||
with open(bash_path, 'r') as f:
|
||||
bash_code = f.read()
|
||||
|
||||
with open(tools_prompt_path, 'r') as f:
|
||||
tools_prompt_code = f.read()
|
||||
|
||||
# Check for the problematic imports
|
||||
prompt_imports_bash = (
|
||||
re.search(
|
||||
r'from openhands\.agenthub\.codeact_agent\.tools\.bash import',
|
||||
prompt_code,
|
||||
)
|
||||
is not None
|
||||
)
|
||||
bash_imports_tools_prompt = (
|
||||
re.search(
|
||||
r'from openhands\.agenthub\.codeact_agent\.tools\.prompt import',
|
||||
bash_code,
|
||||
)
|
||||
is not None
|
||||
)
|
||||
tools_prompt_imports_prompt = (
|
||||
re.search(r'from openhands\.utils\.prompt import', tools_prompt_code)
|
||||
is not None
|
||||
)
|
||||
|
||||
# If all three imports exist, we have a circular import
|
||||
if (
|
||||
prompt_imports_bash
|
||||
and bash_imports_tools_prompt
|
||||
and tools_prompt_imports_prompt
|
||||
):
|
||||
self.fail(
|
||||
'Circular import pattern detected:\n'
|
||||
'openhands.utils.prompt imports from openhands.agenthub.codeact_agent.tools.bash\n'
|
||||
'openhands.agenthub.codeact_agent.tools.bash imports from openhands.agenthub.codeact_agent.tools.prompt\n'
|
||||
'openhands.agenthub.codeact_agent.tools.prompt imports from openhands.utils.prompt'
|
||||
)
|
||||
|
||||
def test_detect_circular_imports_in_server_modules(self):
|
||||
"""
|
||||
Test for circular imports in the server modules that were involved in the stack trace.
|
||||
|
||||
The problematic modules were:
|
||||
- openhands.server.shared
|
||||
- openhands.server.conversation_manager.conversation_manager
|
||||
- openhands.server.session.agent_session
|
||||
- openhands.server.session
|
||||
- openhands.server.session.session
|
||||
"""
|
||||
# Get the project root directory
|
||||
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))
|
||||
|
||||
# Map module names to file paths
|
||||
module_paths = {
|
||||
'openhands.server.shared': os.path.join(
|
||||
project_root, 'openhands/server/shared.py'
|
||||
),
|
||||
'openhands.server.conversation_manager.conversation_manager': os.path.join(
|
||||
project_root,
|
||||
'openhands/server/conversation_manager/conversation_manager.py',
|
||||
),
|
||||
'openhands.server.session.agent_session': os.path.join(
|
||||
project_root, 'openhands/server/session/agent_session.py'
|
||||
),
|
||||
'openhands.server.session.__init__': os.path.join(
|
||||
project_root, 'openhands/server/session/__init__.py'
|
||||
),
|
||||
'openhands.server.session.session': os.path.join(
|
||||
project_root, 'openhands/server/session/session.py'
|
||||
),
|
||||
}
|
||||
|
||||
# Check for circular imports
|
||||
circular_imports = self._find_circular_imports(module_paths)
|
||||
|
||||
# If there are any circular imports, fail the test
|
||||
if circular_imports:
|
||||
circular_import_str = '\n'.join(
|
||||
[
|
||||
f'{module1} -> {module2} -> {module1}'
|
||||
for module1, module2 in circular_imports
|
||||
]
|
||||
)
|
||||
self.fail(
|
||||
f'Circular imports detected in server modules:\n{circular_import_str}'
|
||||
)
|
||||
|
||||
def test_detect_circular_imports_in_mcp_modules(self):
|
||||
"""
|
||||
Test for circular imports in the MCP modules that were involved in the stack trace.
|
||||
|
||||
The problematic modules were:
|
||||
- openhands.mcp
|
||||
- openhands.mcp.utils
|
||||
- openhands.memory.memory
|
||||
"""
|
||||
# Get the project root directory
|
||||
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))
|
||||
|
||||
# Map module names to file paths
|
||||
module_paths = {
|
||||
'openhands.mcp.__init__': os.path.join(
|
||||
project_root, 'openhands/mcp/__init__.py'
|
||||
),
|
||||
'openhands.mcp.utils': os.path.join(project_root, 'openhands/mcp/utils.py'),
|
||||
'openhands.memory.memory': os.path.join(
|
||||
project_root, 'openhands/memory/memory.py'
|
||||
),
|
||||
}
|
||||
|
||||
# Check for circular imports
|
||||
circular_imports = self._find_circular_imports(module_paths)
|
||||
|
||||
# If there are any circular imports, fail the test
|
||||
if circular_imports:
|
||||
circular_import_str = '\n'.join(
|
||||
[
|
||||
f'{module1} -> {module2} -> {module1}'
|
||||
for module1, module2 in circular_imports
|
||||
]
|
||||
)
|
||||
self.fail(
|
||||
f'Circular imports detected in MCP modules:\n{circular_import_str}'
|
||||
)
|
||||
|
||||
def test_detect_complex_circular_import_chains(self):
|
||||
"""
|
||||
Test for complex circular import chains involving multiple modules.
|
||||
|
||||
This test checks for circular dependencies that involve more than two modules,
|
||||
such as A imports B, B imports C, and C imports A.
|
||||
"""
|
||||
# Get the project root directory
|
||||
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))
|
||||
|
||||
# Define the modules involved in the stack trace
|
||||
modules = [
|
||||
'openhands.utils.prompt',
|
||||
'openhands.agenthub.codeact_agent.tools.bash',
|
||||
'openhands.agenthub.codeact_agent.tools.prompt',
|
||||
'openhands.memory.memory',
|
||||
'openhands.memory.conversation_memory',
|
||||
'openhands.server.shared',
|
||||
'openhands.server.conversation_manager.conversation_manager',
|
||||
'openhands.server.session.agent_session',
|
||||
'openhands.server.session.__init__',
|
||||
'openhands.server.session.session',
|
||||
'openhands.mcp.__init__',
|
||||
'openhands.mcp.utils',
|
||||
]
|
||||
|
||||
# Map module names to file paths
|
||||
module_paths = {}
|
||||
for module in modules:
|
||||
if module.endswith('.__init__'):
|
||||
# Handle __init__.py files
|
||||
module_path = module[:-9].replace('.', '/')
|
||||
file_path = os.path.join(project_root, f'{module_path}/__init__.py')
|
||||
else:
|
||||
# Handle regular .py files
|
||||
module_path = module.replace('.', '/')
|
||||
file_path = os.path.join(project_root, f'{module_path}.py')
|
||||
|
||||
if os.path.exists(file_path):
|
||||
module_paths[module] = file_path
|
||||
|
||||
# Build the import graph
|
||||
import_graph = {}
|
||||
for module_name, file_path in module_paths.items():
|
||||
with open(file_path, 'r') as f:
|
||||
source_code = f.read()
|
||||
|
||||
# Extract import statements
|
||||
import_lines = [
|
||||
line.strip()
|
||||
for line in source_code.split('\n')
|
||||
if line.strip().startswith(('import ', 'from '))
|
||||
and not line.strip().startswith('# ')
|
||||
]
|
||||
|
||||
# Parse import statements to get imported modules
|
||||
imported_modules = []
|
||||
for line in import_lines:
|
||||
if line.startswith('import '):
|
||||
# Handle "import module" or "import module as alias"
|
||||
parts = line[7:].split(',')
|
||||
for part in parts:
|
||||
module_part = part.strip().split(' as ')[0].strip()
|
||||
if module_part.startswith('openhands.'):
|
||||
imported_modules.append(module_part)
|
||||
elif line.startswith('from '):
|
||||
# Handle "from module import name" or "from module import name as alias"
|
||||
module_part = line[5:].split(' import ')[0].strip()
|
||||
if module_part.startswith('openhands.'):
|
||||
imported_modules.append(module_part)
|
||||
|
||||
import_graph[module_name] = [
|
||||
m for m in imported_modules if m in module_paths
|
||||
]
|
||||
|
||||
# Check for circular import chains
|
||||
circular_chains = self._find_circular_chains(import_graph)
|
||||
|
||||
# If there are any circular chains, fail the test
|
||||
if circular_chains:
|
||||
circular_chain_str = '\n'.join(
|
||||
[' -> '.join(chain) for chain in circular_chains]
|
||||
)
|
||||
self.fail(f'Complex circular import chains detected:\n{circular_chain_str}')
|
||||
|
||||
def _find_circular_chains(
|
||||
self, import_graph: dict[str, list[str]]
|
||||
) -> list[list[str]]:
|
||||
"""
|
||||
Find circular import chains in the import graph.
|
||||
|
||||
Args:
|
||||
import_graph: Dictionary mapping module names to lists of imported modules
|
||||
|
||||
Returns:
|
||||
List of circular import chains, where each chain is a list of module names
|
||||
"""
|
||||
circular_chains = []
|
||||
|
||||
def dfs(module: str, path: list[str], visited: set[str]):
|
||||
"""
|
||||
Depth-first search to find circular import chains.
|
||||
|
||||
Args:
|
||||
module: Current module being visited
|
||||
path: Current path in the DFS
|
||||
visited: Set of modules visited in the current DFS path
|
||||
"""
|
||||
if module in visited:
|
||||
# Found a circular import chain
|
||||
cycle_start = path.index(module)
|
||||
circular_chains.append(path[cycle_start:] + [module])
|
||||
return
|
||||
|
||||
visited.add(module)
|
||||
path.append(module)
|
||||
|
||||
for imported_module in import_graph.get(module, []):
|
||||
dfs(imported_module, path.copy(), visited.copy())
|
||||
|
||||
# Start DFS from each module
|
||||
for module in import_graph:
|
||||
dfs(module, [], set())
|
||||
|
||||
return circular_chains
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||