mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05270dfe2a | |||
| b9bd04e1cb | |||
| afc499933b | |||
| 8f0e372133 | |||
| 25540c6b4e | |||
| 49627d44ca | |||
| c18c9e2118 | |||
| fb45bb2c92 | |||
| e65e9103d8 | |||
| 29f5bfdf0c | |||
| 8c90ddcea2 | |||
| ba26ad5147 | |||
| 33c6f63589 | |||
| 63365e2793 | |||
| b45cf6d23f | |||
| 80dc8b0fbf | |||
| ab3b5dc947 | |||
| 43bc9a8a1a | |||
| 7902806a92 | |||
| 610856cdd4 | |||
| 500005ec4d | |||
| ac0674287c | |||
| df6c5f4de3 | |||
| 6f1df78b78 | |||
| f8b456962c | |||
| 213fc9d752 | |||
| 8b78397ec4 | |||
| adc6ec77ae | |||
| c1cd21e94d | |||
| 36bf86db36 | |||
| 72ab6aed88 | |||
| 7426b913a1 | |||
| 4b32786588 | |||
| c4929925fb | |||
| 158799b45e | |||
| 4f0f0d0b98 | |||
| 07c9c3016c | |||
| 0f912e09cc |
@@ -0,0 +1,193 @@
|
||||
name: Smoke Tests
|
||||
|
||||
on:
|
||||
# Manual trigger only - uncomment triggers below to enable automatic runs
|
||||
# push:
|
||||
# branches: [main]
|
||||
# pull_request:
|
||||
# branches: [main]
|
||||
# schedule:
|
||||
# - cron: '0 */6 * * *'
|
||||
|
||||
# Manual trigger with environment selection
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: 'Target environment'
|
||||
required: true
|
||||
default: 'staging'
|
||||
type: choice
|
||||
options:
|
||||
- staging
|
||||
- production
|
||||
base_url:
|
||||
description: 'Custom base URL (overrides environment selection)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
# Cancel previous runs on the same branch
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_VERSION: '22'
|
||||
|
||||
jobs:
|
||||
smoke-tests:
|
||||
name: Smoke Tests (${{ github.event.inputs.environment || 'staging' }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: integration_tests/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./integration_tests
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: ./integration_tests
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Determine base URL
|
||||
id: base-url
|
||||
run: |
|
||||
if [ -n "${{ github.event.inputs.base_url }}" ]; then
|
||||
echo "url=${{ github.event.inputs.base_url }}" >> $GITHUB_OUTPUT
|
||||
elif [ "${{ github.event.inputs.environment }}" = "production" ]; then
|
||||
echo "url=https://app.all-hands.dev" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "url=https://staging.all-hands.dev" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Run smoke tests
|
||||
working-directory: ./integration_tests
|
||||
env:
|
||||
BASE_URL: ${{ steps.base-url.outputs.url }}
|
||||
AUTH_METHOD: github
|
||||
GITHUB_TEST_USERNAME: ${{ secrets.SMOKE_TEST_GITHUB_USERNAME }}
|
||||
GITHUB_TEST_PASSWORD: ${{ secrets.SMOKE_TEST_GITHUB_PASSWORD }}
|
||||
GITHUB_TEST_TOTP_SECRET: ${{ secrets.SMOKE_TEST_GITHUB_TOTP_SECRET }}
|
||||
TEST_REPO_URL: ${{ secrets.SMOKE_TEST_REPO_URL }}
|
||||
CI: true
|
||||
run: npm test
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report-${{ github.run_id }}
|
||||
path: integration_tests/playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: test-results-${{ github.run_id }}
|
||||
path: |
|
||||
integration_tests/test-results/
|
||||
integration_tests/playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
# Notify on failure (optional - configure Slack/Discord webhook)
|
||||
# Disabled until automatic triggers are enabled
|
||||
notify-failure:
|
||||
name: Notify on Failure
|
||||
runs-on: ubuntu-latest
|
||||
needs: smoke-tests
|
||||
if: false # Disabled - set to: failure() && github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- name: Send notification
|
||||
run: |
|
||||
echo "Smoke tests failed on main branch!"
|
||||
echo "View results: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
# Add Slack/Discord notification here if needed
|
||||
# Example with curl to Slack webhook:
|
||||
# curl -X POST -H 'Content-type: application/json' \
|
||||
# --data '{"text":"🚨 Smoke tests failed on main! <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Results>"}' \
|
||||
# ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
|
||||
# Feature branch smoke tests (disabled - was triggered by PR label)
|
||||
# To enable: uncomment pull_request trigger above and this job
|
||||
feature-branch-test:
|
||||
name: Feature Branch Smoke Test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
if: false # Disabled - set to: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'smoke-test')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: integration_tests/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./integration_tests
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: ./integration_tests
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Extract branch name for URL
|
||||
id: branch
|
||||
run: |
|
||||
BRANCH_NAME="${{ github.head_ref }}"
|
||||
# Sanitize branch name for URL (replace special chars)
|
||||
SANITIZED=$(echo "$BRANCH_NAME" | sed 's/[^a-zA-Z0-9-]/-/g' | tr '[:upper:]' '[:lower:]')
|
||||
echo "name=$SANITIZED" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run smoke tests against feature branch
|
||||
working-directory: ./integration_tests
|
||||
env:
|
||||
BASE_URL: https://${{ steps.branch.outputs.name }}.staging.all-hands.dev
|
||||
AUTH_METHOD: github
|
||||
GITHUB_TEST_USERNAME: ${{ secrets.SMOKE_TEST_GITHUB_USERNAME }}
|
||||
GITHUB_TEST_PASSWORD: ${{ secrets.SMOKE_TEST_GITHUB_PASSWORD }}
|
||||
CI: true
|
||||
run: npm test
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: feature-branch-report-${{ github.run_id }}
|
||||
path: integration_tests/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
- name: Comment on PR with results
|
||||
uses: actions/github-script@v7
|
||||
if: always()
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const outcome = '${{ job.status }}';
|
||||
const branchUrl = 'https://${{ steps.branch.outputs.name }}.staging.all-hands.dev';
|
||||
|
||||
const body = outcome === 'success'
|
||||
? `✅ **Smoke tests passed** against [${branchUrl}](${branchUrl})`
|
||||
: `❌ **Smoke tests failed** against [${branchUrl}](${branchUrl})\n\nView the [test report](${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}) for details.`;
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: body
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+26
-24
@@ -6190,14 +6190,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.13.0"
|
||||
version = "1.12.0"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_agent_server-1.13.0-py3-none-any.whl", hash = "sha256:88bb8bfb03ff0cc7a7d32ffabd108d0a284f4333f33a9de27ce158b6d828bc29"},
|
||||
{file = "openhands_agent_server-1.13.0.tar.gz", hash = "sha256:6f8b296c0f26a478d4eb49668a353e2b6997c39022c2bbcc36325f5f08887a7a"},
|
||||
{file = "openhands_agent_server-1.12.0-py3-none-any.whl", hash = "sha256:3bd62fef10092f1155af116a8a7417041d574eff9d4e4b6f7a24bfc432de2fad"},
|
||||
{file = "openhands_agent_server-1.12.0.tar.gz", hash = "sha256:7ea7ce579175f713ed68b68cde5d685ef694627ac7bbff40d2e22913f065c46d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6214,7 +6214,7 @@ wsproto = ">=1.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-ai"
|
||||
version = "1.5.0"
|
||||
version = "1.4.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
optional = false
|
||||
python-versions = "^3.12,<3.14"
|
||||
@@ -6259,9 +6259,9 @@ memory-profiler = ">=0.61"
|
||||
numpy = "*"
|
||||
openai = "2.8"
|
||||
openhands-aci = "0.3.3"
|
||||
openhands-agent-server = "1.13"
|
||||
openhands-sdk = "1.13"
|
||||
openhands-tools = "1.13"
|
||||
openhands-agent-server = "1.12"
|
||||
openhands-sdk = "1.12"
|
||||
openhands-tools = "1.12"
|
||||
opentelemetry-api = ">=1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1"
|
||||
pathspec = ">=0.12.1"
|
||||
@@ -6315,14 +6315,14 @@ url = ".."
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.13.0"
|
||||
version = "1.12.0"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_sdk-1.13.0-py3-none-any.whl", hash = "sha256:ec83f9fa2934aae9c4ce1c0365a7037f7e17869affa44a40e71ba49d2bef7185"},
|
||||
{file = "openhands_sdk-1.13.0.tar.gz", hash = "sha256:fbb2a2dc4852ea23cc697a36fb3f95ca47cfef432b0d195c496de6f374caad9c"},
|
||||
{file = "openhands_sdk-1.12.0-py3-none-any.whl", hash = "sha256:857793f5c27fd63c0d4d37762550e6c504a03dd06116475c23adcc14bb5c4c02"},
|
||||
{file = "openhands_sdk-1.12.0.tar.gz", hash = "sha256:ac348e7134ea21e1ab453978962504aff8eb47e62df1fb7a503d769d55658ea9"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6345,14 +6345,14 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.13.0"
|
||||
version = "1.12.0"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_tools-1.13.0-py3-none-any.whl", hash = "sha256:87073b868e20f9c769497f480e0d15b14ca41314c3d1cb5076029f37408a1d68"},
|
||||
{file = "openhands_tools-1.13.0.tar.gz", hash = "sha256:e1181701efab5bc3133566e3b1640027824147438959cd8ce7430c941896704d"},
|
||||
{file = "openhands_tools-1.12.0-py3-none-any.whl", hash = "sha256:57207e9e30f9d7fe9121cd21b072580cfdc2a00831edeaf8e8d685d721bb9e33"},
|
||||
{file = "openhands_tools-1.12.0.tar.gz", hash = "sha256:f2b4d81d0b6771f5416f8b702db09a14999fa8e553073bcf38f344e29aae770c"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -13771,22 +13771,24 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "tornado"
|
||||
version = "6.5.5"
|
||||
version = "6.5.4"
|
||||
description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6"},
|
||||
{file = "tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1"},
|
||||
{file = "tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -334,10 +334,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
await super().save_app_conversation_info(info)
|
||||
|
||||
# Get current user_id for SAAS metadata
|
||||
# Fall back to info.created_by_user_id for webhook callbacks (which use ADMIN context)
|
||||
user_id_str = await self.user_context.get_user_id()
|
||||
if not user_id_str and info.created_by_user_id:
|
||||
user_id_str = info.created_by_user_id
|
||||
if user_id_str:
|
||||
# Convert string user_id to UUID
|
||||
user_id_uuid = UUID(user_id_str)
|
||||
|
||||
@@ -663,131 +663,3 @@ class TestSaasSQLAppConversationInfoServiceAdminContext:
|
||||
|
||||
admin_page = await admin_service.search_app_conversation_info()
|
||||
assert len(admin_page.items) == 5
|
||||
|
||||
|
||||
class TestSaasSQLAppConversationInfoServiceWebhookFallback:
|
||||
"""Test suite for webhook callback fallback using info.created_by_user_id."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_with_admin_context_uses_created_by_user_id_fallback(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test that save_app_conversation_info uses info.created_by_user_id when user_context returns None.
|
||||
|
||||
This is the key fix for SDK-created conversations: when the webhook endpoint
|
||||
uses ADMIN context (user_id=None), the service should fall back to using
|
||||
the created_by_user_id from the AppConversationInfo object.
|
||||
"""
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
)
|
||||
|
||||
from openhands.app_server.user.specifiy_user_context import ADMIN
|
||||
|
||||
# Arrange: Create service with ADMIN context (user_id=None)
|
||||
admin_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=ADMIN,
|
||||
)
|
||||
|
||||
# Create conversation info with created_by_user_id set (as would come from sandbox_info)
|
||||
conv_id = uuid4()
|
||||
conv_info = AppConversationInfo(
|
||||
id=conv_id,
|
||||
created_by_user_id=str(USER1_ID), # This should be used as fallback
|
||||
sandbox_id='sandbox_webhook_test',
|
||||
title='Webhook Created Conversation',
|
||||
)
|
||||
|
||||
# Act: Save using ADMIN context
|
||||
await admin_service.save_app_conversation_info(conv_info)
|
||||
|
||||
# Assert: SAAS metadata should be created with user_id from info.created_by_user_id
|
||||
saas_query = select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == str(conv_id)
|
||||
)
|
||||
result = await async_session_with_users.execute(saas_query)
|
||||
saas_metadata = result.scalar_one_or_none()
|
||||
|
||||
assert saas_metadata is not None, 'SAAS metadata should be created'
|
||||
assert (
|
||||
saas_metadata.user_id == USER1_ID
|
||||
), 'user_id should match info.created_by_user_id'
|
||||
assert saas_metadata.org_id == ORG1_ID, 'org_id should match user current org'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_with_admin_context_no_user_id_skips_saas_metadata(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test that save_app_conversation_info skips SAAS metadata when both user_context and info have no user_id."""
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
)
|
||||
|
||||
from openhands.app_server.user.specifiy_user_context import ADMIN
|
||||
|
||||
# Arrange: Create service with ADMIN context (user_id=None)
|
||||
admin_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=ADMIN,
|
||||
)
|
||||
|
||||
# Create conversation info without created_by_user_id
|
||||
conv_id = uuid4()
|
||||
conv_info = AppConversationInfo(
|
||||
id=conv_id,
|
||||
created_by_user_id=None, # No user_id available
|
||||
sandbox_id='sandbox_no_user',
|
||||
title='No User Conversation',
|
||||
)
|
||||
|
||||
# Act: Save using ADMIN context with no user_id fallback
|
||||
await admin_service.save_app_conversation_info(conv_info)
|
||||
|
||||
# Assert: SAAS metadata should NOT be created
|
||||
saas_query = select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == str(conv_id)
|
||||
)
|
||||
result = await async_session_with_users.execute(saas_query)
|
||||
saas_metadata = result.scalar_one_or_none()
|
||||
|
||||
assert (
|
||||
saas_metadata is None
|
||||
), 'SAAS metadata should not be created without user_id'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_created_conversation_visible_to_user(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test end-to-end: conversation saved via webhook is visible to the owning user."""
|
||||
from openhands.app_server.user.specifiy_user_context import ADMIN
|
||||
|
||||
# Arrange: Save conversation using ADMIN context (simulating webhook)
|
||||
admin_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=ADMIN,
|
||||
)
|
||||
|
||||
conv_id = uuid4()
|
||||
conv_info = AppConversationInfo(
|
||||
id=conv_id,
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_webhook_e2e',
|
||||
title='E2E Webhook Conversation',
|
||||
)
|
||||
await admin_service.save_app_conversation_info(conv_info)
|
||||
|
||||
# Act: Query as the owning user
|
||||
user1_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
|
||||
)
|
||||
user1_page = await user1_service.search_app_conversation_info()
|
||||
|
||||
# Assert: User should see the webhook-created conversation
|
||||
assert len(user1_page.items) == 1
|
||||
assert user1_page.items[0].id == conv_id
|
||||
assert user1_page.items[0].title == 'E2E Webhook Conversation'
|
||||
|
||||
@@ -87,6 +87,7 @@ export function AgentStatus({
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1 min-w-0", className)}>
|
||||
<span
|
||||
data-testid="agent-status-text"
|
||||
className="text-[11px] text-white font-normal leading-5 flex-1 min-w-0 max-w-full whitespace-normal break-words"
|
||||
title={t(statusCode)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# OpenHands Integration Tests - Environment Configuration
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# =============================================================================
|
||||
# TARGET ENVIRONMENT
|
||||
# =============================================================================
|
||||
|
||||
# Base URL for the application under test
|
||||
# Options:
|
||||
# - https://staging.all-hands.dev (default)
|
||||
# - https://app.all-hands.dev (production)
|
||||
# - https://<feature_branch>.staging.all-hands.dev (feature branches)
|
||||
# - http://localhost:3000 (local development)
|
||||
BASE_URL=https://staging.all-hands.dev
|
||||
|
||||
# Environment name (optional, used for logging)
|
||||
# Options: staging, production, local, feature
|
||||
TEST_ENV=staging
|
||||
|
||||
# =============================================================================
|
||||
# AUTHENTICATION
|
||||
# =============================================================================
|
||||
|
||||
# Authentication method
|
||||
# Options:
|
||||
# - github (default): Use GitHub OAuth
|
||||
# - keycloak: Use Keycloak authentication
|
||||
# - skip: Skip auth setup (use existing fixtures/auth.json)
|
||||
AUTH_METHOD=github
|
||||
|
||||
# GitHub OAuth Credentials (required for AUTH_METHOD=github)
|
||||
# Create a test account specifically for automation testing
|
||||
GITHUB_TEST_USERNAME=your-github-test-username
|
||||
GITHUB_TEST_PASSWORD=your-github-test-password
|
||||
|
||||
# GitHub 2FA TOTP Secret (optional, only if 2FA is enabled on test account)
|
||||
# You can get this when setting up 2FA - it's the secret key shown
|
||||
# GITHUB_TEST_TOTP_SECRET=your-totp-secret
|
||||
|
||||
# Keycloak Credentials (required for AUTH_METHOD=keycloak)
|
||||
# KEYCLOAK_URL=https://auth.your-domain.com
|
||||
# KEYCLOAK_USERNAME=test-user
|
||||
# KEYCLOAK_PASSWORD=test-password
|
||||
|
||||
# =============================================================================
|
||||
# TEST CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
# Repository to use for testing (should be a private repo the test user has access to)
|
||||
#TEST_REPO_URL=https://github.com/OpenHands/deploy
|
||||
|
||||
# Prompt to send to the agent during smoke tests
|
||||
TEST_PROMPT="Flip a coin!"
|
||||
|
||||
# =============================================================================
|
||||
# CI/CD CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
# Set to true when running in CI environment
|
||||
# CI=true
|
||||
|
||||
# Playwright specific settings
|
||||
# PLAYWRIGHT_HTML_REPORT=playwright-report
|
||||
|
||||
# =============================================================================
|
||||
# DEBUG OPTIONS
|
||||
# =============================================================================
|
||||
|
||||
# Enable debug logging
|
||||
# DEBUG=pw:api
|
||||
|
||||
# Keep browser open after tests (headed mode only)
|
||||
# PWDEBUG=1
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"extends": [
|
||||
"airbnb-base",
|
||||
"airbnb-typescript/base",
|
||||
"prettier",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"plugins": ["prettier", "unused-imports"],
|
||||
"rules": {
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"prettier/prettier": ["error"],
|
||||
"@typescript-eslint/prefer-optional-chain": "error",
|
||||
"import/extensions": [
|
||||
"error",
|
||||
"ignorePackages",
|
||||
{
|
||||
"": "never",
|
||||
"ts": "never"
|
||||
}
|
||||
]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts"],
|
||||
"rules": {
|
||||
"no-param-reassign": [
|
||||
"error",
|
||||
{
|
||||
"props": true,
|
||||
"ignorePropertyModificationsFor": ["acc", "page"]
|
||||
}
|
||||
],
|
||||
"no-restricted-syntax": "off",
|
||||
"import/prefer-default-export": "off",
|
||||
"no-underscore-dangle": "off",
|
||||
"import/no-extraneous-dependencies": "off",
|
||||
"no-console": "off",
|
||||
"no-await-in-loop": "off",
|
||||
"class-methods-use-this": "off",
|
||||
"@typescript-eslint/no-use-before-define": "off",
|
||||
"no-plusplus": "off",
|
||||
"no-promise-executor-return": "off",
|
||||
"@typescript-eslint/no-throw-literal": "off",
|
||||
"@typescript-eslint/no-shadow": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_"
|
||||
}
|
||||
]
|
||||
},
|
||||
"parserOptions": {
|
||||
"project": ["./tsconfig.json"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Environment files (contain secrets)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Test artifacts
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
|
||||
# Authentication state (contains session tokens)
|
||||
fixtures/auth.json
|
||||
|
||||
# TypeScript build output
|
||||
*.tsbuildinfo
|
||||
dist/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Debug logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Screenshots and videos from test runs
|
||||
**/*.png
|
||||
**/*.webm
|
||||
**/*.mp4
|
||||
|
||||
# Keep the fixtures directory structure
|
||||
!fixtures/.gitkeep
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
# OpenHands Integration Tests
|
||||
|
||||
End-to-end smoke tests for OpenHands using [Playwright](https://playwright.dev/).
|
||||
|
||||
## Overview
|
||||
|
||||
These integration tests verify the critical path of the OpenHands application:
|
||||
|
||||
1. ✅ User authentication (GitHub OAuth / Keycloak)
|
||||
2. ✅ Home screen accessibility
|
||||
3. ✅ Repository selection
|
||||
4. ✅ Conversation creation
|
||||
5. ✅ Agent interaction without errors
|
||||
6. ✅ GitHub Resolver integration (enterprise)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 20.0.0 or higher
|
||||
- A GitHub test account with access to the test repository
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
cd integration_tests
|
||||
npm install
|
||||
npx playwright install chromium # Install browser
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
1. Copy the example environment file:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Edit `.env` with your test credentials:
|
||||
|
||||
```env
|
||||
GITHUB_TEST_USERNAME=your-test-account
|
||||
GITHUB_TEST_PASSWORD=your-test-password
|
||||
# Enable this only if your user has access to this repository
|
||||
#TEST_REPO_URL=https://github.com/OpenHands/deploy
|
||||
```
|
||||
|
||||
### Run Tests
|
||||
|
||||
```bash
|
||||
# Run all smoke tests against staging
|
||||
npm test
|
||||
|
||||
# Run with visible browser
|
||||
npm run test:headed
|
||||
|
||||
# Run with Playwright debugger
|
||||
npm run test:debug
|
||||
|
||||
# Run with UI mode (interactive)
|
||||
npm run test:ui
|
||||
```
|
||||
|
||||
## Environment Support
|
||||
|
||||
Tests can run against different environments:
|
||||
|
||||
### Staging (Default)
|
||||
|
||||
```bash
|
||||
npm run test:staging
|
||||
# or
|
||||
BASE_URL=https://staging.all-hands.dev npm test
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
npm run test:production
|
||||
# or
|
||||
BASE_URL=https://app.all-hands.dev npm test
|
||||
```
|
||||
|
||||
### Feature Branches
|
||||
|
||||
```bash
|
||||
BASE_URL=https://my-feature-branch.staging.all-hands.dev npm test
|
||||
```
|
||||
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
BASE_URL=http://localhost:3000 npm test
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
### GitHub OAuth (Default)
|
||||
|
||||
The tests use GitHub OAuth for authentication. You'll need a dedicated test account.
|
||||
|
||||
**Required Environment Variables:**
|
||||
- `GITHUB_TEST_USERNAME` - GitHub username
|
||||
- `GITHUB_TEST_PASSWORD` - GitHub password
|
||||
- `GITHUB_TEST_TOTP_SECRET` - (Optional) 2FA TOTP secret
|
||||
|
||||
**Recommendations:**
|
||||
- Use a dedicated test account, not your personal account
|
||||
- Disable 2FA on the test account if possible (simpler automation)
|
||||
- If 2FA is required, you'll need to implement TOTP generation (see below)
|
||||
|
||||
### Keycloak Authentication
|
||||
|
||||
For Keycloak-based authentication:
|
||||
|
||||
```env
|
||||
AUTH_METHOD=keycloak
|
||||
KEYCLOAK_URL=https://auth.your-domain.com
|
||||
KEYCLOAK_USERNAME=test-user
|
||||
KEYCLOAK_PASSWORD=test-password
|
||||
```
|
||||
|
||||
### Reusing Authentication State
|
||||
|
||||
After running tests once, the authentication state is saved to `fixtures/auth.json`. To skip the auth flow on subsequent runs:
|
||||
|
||||
```env
|
||||
AUTH_METHOD=skip
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
integration_tests/
|
||||
├── fixtures/ # Test fixtures and auth state
|
||||
│ └── auth.json # Saved authentication state (generated)
|
||||
├── pages/ # Page Object Models
|
||||
│ ├── BasePage.ts # Base page with common utilities
|
||||
│ ├── HomePage.ts # Home screen interactions
|
||||
│ ├── ConversationPage.ts # Conversation/chat interactions
|
||||
│ └── index.ts # Page exports
|
||||
├── tests/ # Test specifications
|
||||
│ ├── global-setup.ts # Authentication setup
|
||||
│ └── smoke.spec.ts # Smoke test suite
|
||||
├── utils/ # Utility functions
|
||||
├── .env.example # Environment configuration template
|
||||
├── playwright.config.ts # Playwright configuration
|
||||
├── package.json # Dependencies and scripts
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Writing Tests
|
||||
|
||||
### Using Page Objects
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { HomePage, ConversationPage } from "../pages";
|
||||
|
||||
test("example test", async ({ page }) => {
|
||||
const homePage = new HomePage(page);
|
||||
const conversationPage = new ConversationPage(page);
|
||||
|
||||
// Navigate and verify home screen
|
||||
await homePage.goto();
|
||||
await expect(homePage.homeScreen).toBeVisible();
|
||||
|
||||
// Start a conversation
|
||||
await homePage.selectRepository("https://github.com/owner/repo");
|
||||
await homePage.startNewConversation();
|
||||
|
||||
// Interact with agent
|
||||
await conversationPage.waitForConversationReady();
|
||||
await conversationPage.executePrompt("Your prompt here");
|
||||
await conversationPage.verifyNoErrors();
|
||||
});
|
||||
```
|
||||
|
||||
### Test Tags
|
||||
|
||||
Tests are organized with tags:
|
||||
|
||||
- `@smoke` - Core smoke tests (run by default)
|
||||
- `@critical` - Critical functionality that must always work
|
||||
|
||||
```bash
|
||||
# Run only smoke tests
|
||||
npm run test:smoke
|
||||
|
||||
# Run specific tag
|
||||
npx playwright test --grep @critical
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
The tests can be run in GitHub Actions. See `.github/workflows/smoke-tests.yml`.
|
||||
|
||||
**Required Secrets:**
|
||||
- `GITHUB_TEST_USERNAME` - Test account username
|
||||
- `GITHUB_TEST_PASSWORD` - Test account password
|
||||
|
||||
### Example Workflow
|
||||
|
||||
```yaml
|
||||
name: Smoke Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '0 */6 * * *' # Every 6 hours
|
||||
|
||||
jobs:
|
||||
smoke-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./integration_tests
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright
|
||||
working-directory: ./integration_tests
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run smoke tests
|
||||
working-directory: ./integration_tests
|
||||
env:
|
||||
BASE_URL: https://staging.all-hands.dev
|
||||
GITHUB_TEST_USERNAME: ${{ secrets.GITHUB_TEST_USERNAME }}
|
||||
GITHUB_TEST_PASSWORD: ${{ secrets.GITHUB_TEST_PASSWORD }}
|
||||
run: npm test
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: integration_tests/playwright-report/
|
||||
retention-days: 30
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Authentication Fails
|
||||
|
||||
1. Verify credentials are correct
|
||||
2. Check if 2FA is enabled (need TOTP secret)
|
||||
3. Check if account is locked or needs verification
|
||||
4. Try running `AUTH_METHOD=skip` with manual login first
|
||||
|
||||
### Tests Timeout
|
||||
|
||||
1. Increase timeout in `playwright.config.ts`
|
||||
2. Check if the environment is accessible
|
||||
3. Check agent response times
|
||||
|
||||
### Debug Mode
|
||||
|
||||
```bash
|
||||
# Run with headed browser and pause on failure
|
||||
PWDEBUG=1 npm test
|
||||
|
||||
# Generate test code interactively
|
||||
npm run codegen
|
||||
```
|
||||
|
||||
### View Test Report
|
||||
|
||||
```bash
|
||||
npm run report
|
||||
```
|
||||
|
||||
## Adding 2FA Support
|
||||
|
||||
If your test account requires 2FA, install `otplib`:
|
||||
|
||||
```bash
|
||||
npm install otplib
|
||||
```
|
||||
|
||||
Then update `global-setup.ts`:
|
||||
|
||||
```typescript
|
||||
import { authenticator } from 'otplib';
|
||||
|
||||
async function generateTOTP(secret: string): Promise<string> {
|
||||
return authenticator.generate(secret);
|
||||
}
|
||||
```
|
||||
|
||||
## GitHub Resolver Integration Tests
|
||||
|
||||
The GitHub Resolver tests verify the end-to-end flow of the resolver integration, where GitHub webhooks trigger OpenHands to work on issues and pull requests.
|
||||
|
||||
### Architecture
|
||||
|
||||
The tests use a **Mock GitHub Server** instead of connecting to the real GitHub API. This allows:
|
||||
|
||||
- Complete control over webhook payloads and responses
|
||||
- Testing without requiring real GitHub credentials or installations
|
||||
- Isolation from GitHub's rate limits and service availability
|
||||
- Reproducible test scenarios
|
||||
|
||||
### Mock GitHub Server
|
||||
|
||||
The mock server (`mocks/github-mock-server.ts`) simulates:
|
||||
|
||||
- GitHub REST API endpoints (repos, issues, comments, reactions)
|
||||
- GitHub App installation token generation
|
||||
- Webhook signature verification
|
||||
- Recording of outgoing responses (comments posted by the resolver)
|
||||
|
||||
### Running GitHub Resolver Tests
|
||||
|
||||
1. **Start the OpenHands application with enterprise features:**
|
||||
|
||||
```bash
|
||||
# From the project root
|
||||
cd enterprise
|
||||
make start-backend
|
||||
```
|
||||
|
||||
2. **Configure environment variables:**
|
||||
|
||||
```bash
|
||||
# In integration_tests/.env
|
||||
GITHUB_APP_WEBHOOK_SECRET=test-webhook-secret
|
||||
APP_PORT=12000
|
||||
MOCK_GITHUB_PORT=9999
|
||||
```
|
||||
|
||||
3. **Run the tests:**
|
||||
|
||||
```bash
|
||||
cd integration_tests
|
||||
npm run test:github-resolver
|
||||
```
|
||||
|
||||
### Mock Server Standalone Mode
|
||||
|
||||
You can run the mock GitHub server standalone for debugging:
|
||||
|
||||
```bash
|
||||
npm run mock:github
|
||||
```
|
||||
|
||||
This starts the server on port 9999 (configurable via `MOCK_GITHUB_PORT`).
|
||||
|
||||
### Test Endpoints
|
||||
|
||||
The mock server exposes test control endpoints:
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/_health` | GET | Health check |
|
||||
| `/_test/webhook-events` | GET | Get recorded webhook events |
|
||||
| `/_test/outgoing-responses` | GET | Get responses posted by resolver |
|
||||
| `/_test/clear-events` | POST | Clear recorded events |
|
||||
| `/_test/reset` | POST | Reset all mock data |
|
||||
| `/_test/trigger-webhook` | POST | Trigger a webhook to target URL |
|
||||
|
||||
### Test Scenarios
|
||||
|
||||
The GitHub Resolver tests cover:
|
||||
|
||||
1. **Issue Labeled** - Adding the "openhands" label to an issue
|
||||
2. **Issue Comment** - Commenting "@openhands" on an issue
|
||||
3. **PR Review Comment** - Commenting "@openhands" on a PR review
|
||||
4. **Error Handling** - Invalid signatures, missing installation IDs
|
||||
|
||||
### Customizing Test Data
|
||||
|
||||
Edit `mocks/github-mock-server.ts` to modify the default test data:
|
||||
|
||||
- Repository information
|
||||
- Issue content
|
||||
- Installation configurations
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use dedicated test accounts** - Don't use personal accounts
|
||||
2. **Keep credentials secure** - Never commit `.env` files
|
||||
3. **Run tests sequentially** - Smoke tests share state
|
||||
4. **Clean up after tests** - Stop agents, close conversations
|
||||
5. **Use meaningful assertions** - Check for specific elements
|
||||
6. **Add screenshots on failure** - Helps debug CI failures
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new tests:
|
||||
|
||||
1. Add new Page Objects for new pages/features
|
||||
2. Follow existing naming conventions
|
||||
3. Use appropriate test tags
|
||||
4. Document any new environment variables
|
||||
5. Update this README if needed
|
||||
@@ -0,0 +1,2 @@
|
||||
# This file ensures the fixtures directory is tracked by git
|
||||
# The auth.json file will be generated during test setup
|
||||
@@ -0,0 +1,705 @@
|
||||
/**
|
||||
* Mock GitHub Server for Integration Testing
|
||||
*
|
||||
* This server simulates GitHub API endpoints used by the OpenHands resolver:
|
||||
* - GitHub App webhooks (issue labeled, issue comment, PR comment, etc.)
|
||||
* - GitHub REST API endpoints (repos, issues, comments, pulls)
|
||||
* - GitHub GraphQL API
|
||||
*
|
||||
* The mock server allows testing the resolver integration without connecting
|
||||
* to the real GitHub service.
|
||||
*/
|
||||
|
||||
import http from "http";
|
||||
import crypto from "crypto";
|
||||
|
||||
// Types for mock data
|
||||
interface MockIssue {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
state: "open" | "closed";
|
||||
labels: Array<{ name: string; id: number }>;
|
||||
user: { login: string; id: number };
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
comments: MockComment[];
|
||||
reactions: string[];
|
||||
}
|
||||
|
||||
interface MockComment {
|
||||
id: number;
|
||||
body: string;
|
||||
user: { login: string; id: number };
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface MockRepository {
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
private: boolean;
|
||||
owner: { login: string; id: number };
|
||||
default_branch: string;
|
||||
node_id: string;
|
||||
}
|
||||
|
||||
interface MockInstallation {
|
||||
id: number;
|
||||
account: { login: string; id: number };
|
||||
repositories: MockRepository[];
|
||||
access_token: string;
|
||||
}
|
||||
|
||||
interface WebhookEvent {
|
||||
action: string;
|
||||
payload: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Mock data store
|
||||
class MockGitHubDataStore {
|
||||
private repositories: Map<string, MockRepository> = new Map();
|
||||
|
||||
private issues: Map<string, Map<number, MockIssue>> = new Map();
|
||||
|
||||
private installations: Map<number, MockInstallation> = new Map();
|
||||
|
||||
private webhookEvents: WebhookEvent[] = [];
|
||||
|
||||
private nextCommentId = 1000;
|
||||
|
||||
private outgoingWebhookResponses: Array<{
|
||||
body: string;
|
||||
timestamp: string;
|
||||
}> = [];
|
||||
|
||||
constructor() {
|
||||
this.initializeDefaultData();
|
||||
}
|
||||
|
||||
private initializeDefaultData() {
|
||||
// Create a default test repository
|
||||
const testRepo: MockRepository = {
|
||||
id: 123456789,
|
||||
name: "test-repo",
|
||||
full_name: "test-owner/test-repo",
|
||||
private: false,
|
||||
owner: { login: "test-owner", id: 1000 },
|
||||
default_branch: "main",
|
||||
node_id: "R_kgDOTest123",
|
||||
};
|
||||
this.repositories.set(testRepo.full_name, testRepo);
|
||||
|
||||
// Create a test issue
|
||||
const testIssue: MockIssue = {
|
||||
number: 1,
|
||||
title: "Test Issue for OpenHands Resolver",
|
||||
body: "This is a test issue to verify the resolver integration works correctly. Please add a README file.",
|
||||
state: "open",
|
||||
labels: [],
|
||||
user: { login: "test-user", id: 2000 },
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
comments: [],
|
||||
reactions: [],
|
||||
};
|
||||
this.issues.set(testRepo.full_name, new Map([[1, testIssue]]));
|
||||
|
||||
// Create a default installation
|
||||
const testInstallation: MockInstallation = {
|
||||
id: 12345,
|
||||
account: { login: "test-owner", id: 1000 },
|
||||
repositories: [testRepo],
|
||||
access_token: "ghs_mock_installation_token_12345",
|
||||
};
|
||||
this.installations.set(testInstallation.id, testInstallation);
|
||||
}
|
||||
|
||||
getRepository(fullName: string): MockRepository | undefined {
|
||||
return this.repositories.get(fullName);
|
||||
}
|
||||
|
||||
getIssue(fullName: string, issueNumber: number): MockIssue | undefined {
|
||||
return this.issues.get(fullName)?.get(issueNumber);
|
||||
}
|
||||
|
||||
getIssues(fullName: string): MockIssue[] {
|
||||
const repoIssues = this.issues.get(fullName);
|
||||
return repoIssues ? Array.from(repoIssues.values()) : [];
|
||||
}
|
||||
|
||||
addComment(
|
||||
fullName: string,
|
||||
issueNumber: number,
|
||||
body: string,
|
||||
user: { login: string; id: number },
|
||||
): MockComment {
|
||||
const issue = this.getIssue(fullName, issueNumber);
|
||||
if (!issue) throw new Error(`Issue not found: ${fullName}#${issueNumber}`);
|
||||
|
||||
const comment: MockComment = {
|
||||
id: this.nextCommentId++,
|
||||
body,
|
||||
user,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
issue.comments.push(comment);
|
||||
issue.updated_at = new Date().toISOString();
|
||||
return comment;
|
||||
}
|
||||
|
||||
addReaction(fullName: string, issueNumber: number, reaction: string): void {
|
||||
const issue = this.getIssue(fullName, issueNumber);
|
||||
if (issue) {
|
||||
issue.reactions.push(reaction);
|
||||
}
|
||||
}
|
||||
|
||||
addLabel(fullName: string, issueNumber: number, label: string): void {
|
||||
const issue = this.getIssue(fullName, issueNumber);
|
||||
if (issue) {
|
||||
issue.labels.push({ name: label, id: Date.now() });
|
||||
issue.updated_at = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
getInstallation(id: number): MockInstallation | undefined {
|
||||
return this.installations.get(id);
|
||||
}
|
||||
|
||||
getAllRepositories(): MockRepository[] {
|
||||
return Array.from(this.repositories.values());
|
||||
}
|
||||
|
||||
recordWebhookEvent(action: string, payload: Record<string, unknown>): void {
|
||||
this.webhookEvents.push({
|
||||
action,
|
||||
payload,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
getWebhookEvents(): WebhookEvent[] {
|
||||
return this.webhookEvents;
|
||||
}
|
||||
|
||||
recordOutgoingWebhookResponse(body: string): void {
|
||||
this.outgoingWebhookResponses.push({
|
||||
body,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
getOutgoingWebhookResponses(): Array<{ body: string; timestamp: string }> {
|
||||
return this.outgoingWebhookResponses;
|
||||
}
|
||||
|
||||
clearEvents(): void {
|
||||
this.webhookEvents = [];
|
||||
this.outgoingWebhookResponses = [];
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.repositories.clear();
|
||||
this.issues.clear();
|
||||
this.installations.clear();
|
||||
this.webhookEvents = [];
|
||||
this.outgoingWebhookResponses = [];
|
||||
this.nextCommentId = 1000;
|
||||
this.initializeDefaultData();
|
||||
}
|
||||
}
|
||||
|
||||
const dataStore = new MockGitHubDataStore();
|
||||
|
||||
// Webhook secret for signature verification
|
||||
const WEBHOOK_SECRET =
|
||||
process.env.MOCK_GITHUB_WEBHOOK_SECRET || "test-webhook-secret";
|
||||
|
||||
// Generate webhook signature
|
||||
function generateWebhookSignature(payload: string): string {
|
||||
const hmac = crypto.createHmac("sha256", WEBHOOK_SECRET);
|
||||
hmac.update(payload);
|
||||
return `sha256=${hmac.digest("hex")}`;
|
||||
}
|
||||
|
||||
// Parse URL path and extract params
|
||||
function parseRoute(
|
||||
url: string,
|
||||
pattern: RegExp,
|
||||
): Record<string, string> | null {
|
||||
const match = url.match(pattern);
|
||||
if (!match) return null;
|
||||
return match.groups || {};
|
||||
}
|
||||
|
||||
// JSON response helper
|
||||
function jsonResponse(
|
||||
res: http.ServerResponse,
|
||||
data: unknown,
|
||||
status = 200,
|
||||
): void {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
async function parseBody(req: http.IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => resolve(body));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
// Request handlers
|
||||
const handlers: Array<{
|
||||
method: string;
|
||||
pattern: RegExp;
|
||||
handler: (
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
params: Record<string, string>,
|
||||
body?: unknown,
|
||||
) => Promise<void> | void;
|
||||
}> = [
|
||||
// GitHub App root endpoint
|
||||
{
|
||||
method: "GET",
|
||||
pattern: /^\/app$/,
|
||||
handler: (_req, res) => {
|
||||
jsonResponse(res, {
|
||||
id: 123456,
|
||||
slug: "openhands-test-app",
|
||||
name: "OpenHands Test App",
|
||||
owner: { login: "test-owner", id: 1000 },
|
||||
permissions: {
|
||||
issues: "write",
|
||||
pull_requests: "write",
|
||||
contents: "write",
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// Get repository
|
||||
{
|
||||
method: "GET",
|
||||
pattern: /^\/repos\/(?<owner>[^/]+)\/(?<repo>[^/]+)$/,
|
||||
handler: (_req, res, params) => {
|
||||
const fullName = `${params.owner}/${params.repo}`;
|
||||
const repo = dataStore.getRepository(fullName);
|
||||
if (repo) {
|
||||
jsonResponse(res, repo);
|
||||
} else {
|
||||
jsonResponse(res, { message: "Not Found" }, 404);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Get issue
|
||||
{
|
||||
method: "GET",
|
||||
pattern:
|
||||
/^\/repos\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/issues\/(?<number>\d+)$/,
|
||||
handler: (_req, res, params) => {
|
||||
const fullName = `${params.owner}/${params.repo}`;
|
||||
const issue = dataStore.getIssue(fullName, parseInt(params.number, 10));
|
||||
if (issue) {
|
||||
const repo = dataStore.getRepository(fullName);
|
||||
jsonResponse(res, {
|
||||
...issue,
|
||||
url: `https://api.github.com/repos/${fullName}/issues/${issue.number}`,
|
||||
html_url: `https://github.com/${fullName}/issues/${issue.number}`,
|
||||
repository: repo,
|
||||
});
|
||||
} else {
|
||||
jsonResponse(res, { message: "Not Found" }, 404);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// List issues
|
||||
{
|
||||
method: "GET",
|
||||
pattern: /^\/repos\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/issues$/,
|
||||
handler: (_req, res, params) => {
|
||||
const fullName = `${params.owner}/${params.repo}`;
|
||||
const issues = dataStore.getIssues(fullName);
|
||||
jsonResponse(res, issues);
|
||||
},
|
||||
},
|
||||
|
||||
// Get issue comments
|
||||
{
|
||||
method: "GET",
|
||||
pattern:
|
||||
/^\/repos\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/issues\/(?<number>\d+)\/comments$/,
|
||||
handler: (_req, res, params) => {
|
||||
const fullName = `${params.owner}/${params.repo}`;
|
||||
const issue = dataStore.getIssue(fullName, parseInt(params.number, 10));
|
||||
if (issue) {
|
||||
jsonResponse(res, issue.comments);
|
||||
} else {
|
||||
jsonResponse(res, { message: "Not Found" }, 404);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Create issue comment
|
||||
{
|
||||
method: "POST",
|
||||
pattern:
|
||||
/^\/repos\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/issues\/(?<number>\d+)\/comments$/,
|
||||
handler: async (_req, res, params, body) => {
|
||||
const fullName = `${params.owner}/${params.repo}`;
|
||||
const issueNumber = parseInt(params.number, 10);
|
||||
const requestBody = body as { body: string };
|
||||
|
||||
try {
|
||||
const comment = dataStore.addComment(
|
||||
fullName,
|
||||
issueNumber,
|
||||
requestBody.body,
|
||||
{
|
||||
login: "openhands[bot]",
|
||||
id: 99999,
|
||||
},
|
||||
);
|
||||
|
||||
// Record this as an outgoing response (the resolver posting back)
|
||||
dataStore.recordOutgoingWebhookResponse(requestBody.body);
|
||||
|
||||
jsonResponse(res, comment, 201);
|
||||
} catch {
|
||||
jsonResponse(res, { message: "Not Found" }, 404);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Create issue reaction
|
||||
{
|
||||
method: "POST",
|
||||
pattern:
|
||||
/^\/repos\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/issues\/(?<number>\d+)\/reactions$/,
|
||||
handler: async (_req, res, params, body) => {
|
||||
const fullName = `${params.owner}/${params.repo}`;
|
||||
const issueNumber = parseInt(params.number, 10);
|
||||
const requestBody = body as { content: string };
|
||||
|
||||
dataStore.addReaction(fullName, issueNumber, requestBody.content);
|
||||
jsonResponse(res, { id: Date.now(), content: requestBody.content }, 201);
|
||||
},
|
||||
},
|
||||
|
||||
// Add issue label
|
||||
{
|
||||
method: "POST",
|
||||
pattern:
|
||||
/^\/repos\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/issues\/(?<number>\d+)\/labels$/,
|
||||
handler: async (_req, res, params, body) => {
|
||||
const fullName = `${params.owner}/${params.repo}`;
|
||||
const issueNumber = parseInt(params.number, 10);
|
||||
const requestBody = body as { labels: string[] };
|
||||
|
||||
const issue = dataStore.getIssue(fullName, issueNumber);
|
||||
if (issue) {
|
||||
requestBody.labels.forEach((label) =>
|
||||
dataStore.addLabel(fullName, issueNumber, label),
|
||||
);
|
||||
jsonResponse(res, issue.labels, 201);
|
||||
} else {
|
||||
jsonResponse(res, { message: "Not Found" }, 404);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Get installation access token
|
||||
{
|
||||
method: "POST",
|
||||
pattern: /^\/app\/installations\/(?<installation_id>\d+)\/access_tokens$/,
|
||||
handler: (_req, res, params) => {
|
||||
const installation = dataStore.getInstallation(
|
||||
parseInt(params.installation_id, 10),
|
||||
);
|
||||
if (installation) {
|
||||
jsonResponse(
|
||||
res,
|
||||
{
|
||||
token: installation.access_token,
|
||||
expires_at: new Date(Date.now() + 3600000).toISOString(),
|
||||
permissions: {
|
||||
issues: "write",
|
||||
pull_requests: "write",
|
||||
contents: "write",
|
||||
},
|
||||
repository_selection: "all",
|
||||
},
|
||||
201,
|
||||
);
|
||||
} else {
|
||||
jsonResponse(res, { message: "Not Found" }, 404);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Get installation repositories
|
||||
{
|
||||
method: "GET",
|
||||
pattern: /^\/installation\/repositories$/,
|
||||
handler: (_req, res) => {
|
||||
// Return all repositories from all installations
|
||||
const repos = dataStore.getAllRepositories();
|
||||
jsonResponse(res, {
|
||||
total_count: repos.length,
|
||||
repositories: repos,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// Get user
|
||||
{
|
||||
method: "GET",
|
||||
pattern: /^\/user$/,
|
||||
handler: (_req, res) => {
|
||||
jsonResponse(res, {
|
||||
id: 2000,
|
||||
login: "test-user",
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/2000",
|
||||
name: "Test User",
|
||||
email: "test-user@example.com",
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// Get user by username
|
||||
{
|
||||
method: "GET",
|
||||
pattern: /^\/users\/(?<username>[^/]+)$/,
|
||||
handler: (_req, res, params) => {
|
||||
jsonResponse(res, {
|
||||
id: 2000,
|
||||
login: params.username,
|
||||
avatar_url: `https://avatars.githubusercontent.com/u/2000`,
|
||||
name: params.username,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// Get repository collaborator permission
|
||||
{
|
||||
method: "GET",
|
||||
pattern:
|
||||
/^\/repos\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/collaborators\/(?<username>[^/]+)\/permission$/,
|
||||
handler: (_req, res) => {
|
||||
jsonResponse(res, {
|
||||
permission: "write",
|
||||
user: { login: "test-user", id: 2000 },
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// GraphQL endpoint
|
||||
{
|
||||
method: "POST",
|
||||
pattern: /^\/graphql$/,
|
||||
handler: async (_req, res, _params, _body) => {
|
||||
// Return a basic response for common queries
|
||||
// The body would contain { query: string, variables?: Record<string, unknown> }
|
||||
jsonResponse(res, {
|
||||
data: {
|
||||
repository: {
|
||||
id: "R_kgDOTest123",
|
||||
name: "test-repo",
|
||||
owner: { login: "test-owner" },
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// Test control endpoints - Get webhook events
|
||||
{
|
||||
method: "GET",
|
||||
pattern: /^\/_test\/webhook-events$/,
|
||||
handler: (_req, res) => {
|
||||
jsonResponse(res, dataStore.getWebhookEvents());
|
||||
},
|
||||
},
|
||||
|
||||
// Test control endpoints - Get outgoing webhook responses
|
||||
{
|
||||
method: "GET",
|
||||
pattern: /^\/_test\/outgoing-responses$/,
|
||||
handler: (_req, res) => {
|
||||
jsonResponse(res, dataStore.getOutgoingWebhookResponses());
|
||||
},
|
||||
},
|
||||
|
||||
// Test control endpoints - Clear events
|
||||
{
|
||||
method: "POST",
|
||||
pattern: /^\/_test\/clear-events$/,
|
||||
handler: (_req, res) => {
|
||||
dataStore.clearEvents();
|
||||
jsonResponse(res, { status: "cleared" });
|
||||
},
|
||||
},
|
||||
|
||||
// Test control endpoints - Reset data
|
||||
{
|
||||
method: "POST",
|
||||
pattern: /^\/_test\/reset$/,
|
||||
handler: (_req, res) => {
|
||||
dataStore.reset();
|
||||
jsonResponse(res, { status: "reset" });
|
||||
},
|
||||
},
|
||||
|
||||
// Test control endpoints - Trigger webhook
|
||||
{
|
||||
method: "POST",
|
||||
pattern: /^\/_test\/trigger-webhook$/,
|
||||
handler: async (req, res, _params, body) => {
|
||||
const { targetUrl, eventType, payload } = body as {
|
||||
targetUrl: string;
|
||||
eventType: string;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// Record the webhook event
|
||||
dataStore.recordWebhookEvent(eventType, payload);
|
||||
|
||||
// Send the webhook to the target URL
|
||||
const payloadString = JSON.stringify(payload);
|
||||
const signature = generateWebhookSignature(payloadString);
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-GitHub-Event": eventType,
|
||||
"X-Hub-Signature-256": signature,
|
||||
"X-GitHub-Delivery": crypto.randomUUID(),
|
||||
},
|
||||
body: payloadString,
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
jsonResponse(res, {
|
||||
status: "sent",
|
||||
targetUrl,
|
||||
eventType,
|
||||
responseStatus: response.status,
|
||||
responseBody: responseText,
|
||||
});
|
||||
} catch (error) {
|
||||
jsonResponse(
|
||||
res,
|
||||
{
|
||||
status: "error",
|
||||
error: (error as Error).message,
|
||||
},
|
||||
500,
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Health check
|
||||
{
|
||||
method: "GET",
|
||||
pattern: /^\/_health$/,
|
||||
handler: (_req, res) => {
|
||||
jsonResponse(res, { status: "healthy" });
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Create HTTP server
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = req.url || "/";
|
||||
const method = req.method || "GET";
|
||||
|
||||
// Handle CORS preflight
|
||||
if (method === "OPTIONS") {
|
||||
res.writeHead(204, {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Add CORS headers to all responses
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Headers", "*");
|
||||
|
||||
// Parse body for POST/PUT requests
|
||||
let body: unknown;
|
||||
if (method === "POST" || method === "PUT") {
|
||||
const rawBody = await parseBody(req);
|
||||
try {
|
||||
body = JSON.parse(rawBody);
|
||||
} catch {
|
||||
body = rawBody;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to match a handler
|
||||
for (const handler of handlers) {
|
||||
if (handler.method === method) {
|
||||
const params = parseRoute(url.split("?")[0], handler.pattern);
|
||||
if (params !== null) {
|
||||
try {
|
||||
await handler.handler(req, res, params, body);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error(`Error handling ${method} ${url}:`, error);
|
||||
jsonResponse(res, { error: "Internal Server Error" }, 500);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No handler found
|
||||
console.log(`No handler for ${method} ${url}`);
|
||||
jsonResponse(res, { message: "Not Found", path: url }, 404);
|
||||
});
|
||||
|
||||
// Start server
|
||||
const PORT = parseInt(process.env.MOCK_GITHUB_PORT || "9999", 10);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Mock GitHub Server running on port ${PORT}`);
|
||||
console.log(`Webhook secret: ${WEBHOOK_SECRET}`);
|
||||
console.log("\nAvailable endpoints:");
|
||||
console.log(" GET /_health - Health check");
|
||||
console.log(
|
||||
" GET /_test/webhook-events - Get recorded webhook events",
|
||||
);
|
||||
console.log(
|
||||
" GET /_test/outgoing-responses - Get responses posted by the resolver",
|
||||
);
|
||||
console.log(" POST /_test/clear-events - Clear recorded events");
|
||||
console.log(" POST /_test/reset - Reset all mock data");
|
||||
console.log(
|
||||
" POST /_test/trigger-webhook - Trigger a webhook to target URL",
|
||||
);
|
||||
console.log("\nGitHub API endpoints:");
|
||||
console.log(" GET /repos/:owner/:repo");
|
||||
console.log(" GET /repos/:owner/:repo/issues/:number");
|
||||
console.log(" POST /repos/:owner/:repo/issues/:number/comments");
|
||||
console.log(" POST /repos/:owner/:repo/issues/:number/reactions");
|
||||
console.log(" POST /app/installations/:id/access_tokens");
|
||||
console.log(" POST /graphql");
|
||||
});
|
||||
|
||||
export { server, dataStore, generateWebhookSignature, WEBHOOK_SECRET };
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* GitHub Webhook Payload Templates
|
||||
*
|
||||
* These templates mirror the webhook payloads that GitHub sends for various events.
|
||||
* They're used to test the OpenHands resolver integration.
|
||||
*/
|
||||
|
||||
export interface GitHubWebhookPayload {
|
||||
action: string;
|
||||
installation: { id: number };
|
||||
repository: {
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
private: boolean;
|
||||
owner: { login: string; id: number };
|
||||
default_branch: string;
|
||||
};
|
||||
sender: { login: string; id: number };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IssuePayload extends GitHubWebhookPayload {
|
||||
issue: {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
state: string;
|
||||
labels: Array<{ name: string; id: number }>;
|
||||
user: { login: string; id: number };
|
||||
};
|
||||
label?: { name: string; id: number };
|
||||
}
|
||||
|
||||
export interface IssueCommentPayload extends GitHubWebhookPayload {
|
||||
issue: {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
state: string;
|
||||
labels: Array<{ name: string; id: number }>;
|
||||
user: { login: string; id: number };
|
||||
pull_request?: { url: string };
|
||||
};
|
||||
comment: {
|
||||
id: number;
|
||||
body: string;
|
||||
user: { login: string; id: number };
|
||||
};
|
||||
}
|
||||
|
||||
export interface PullRequestReviewCommentPayload extends GitHubWebhookPayload {
|
||||
pull_request: {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
state: string;
|
||||
head: { ref: string; sha: string };
|
||||
base: { ref: string };
|
||||
user: { login: string; id: number };
|
||||
};
|
||||
comment: {
|
||||
id: number;
|
||||
node_id: string;
|
||||
body: string;
|
||||
path: string;
|
||||
line: number;
|
||||
user: { login: string; id: number };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a base webhook payload with common fields
|
||||
*/
|
||||
function createBasePayload(params: {
|
||||
installationId?: number;
|
||||
repositoryId?: number;
|
||||
repositoryName?: string;
|
||||
repositoryOwner?: string;
|
||||
senderLogin?: string;
|
||||
senderId?: number;
|
||||
isPrivate?: boolean;
|
||||
}): GitHubWebhookPayload {
|
||||
const {
|
||||
installationId = 12345,
|
||||
repositoryId = 123456789,
|
||||
repositoryName = "test-repo",
|
||||
repositoryOwner = "test-owner",
|
||||
senderLogin = "test-user",
|
||||
senderId = 2000,
|
||||
isPrivate = false,
|
||||
} = params;
|
||||
|
||||
return {
|
||||
action: "",
|
||||
installation: { id: installationId },
|
||||
repository: {
|
||||
id: repositoryId,
|
||||
name: repositoryName,
|
||||
full_name: `${repositoryOwner}/${repositoryName}`,
|
||||
private: isPrivate,
|
||||
owner: { login: repositoryOwner, id: 1000 },
|
||||
default_branch: "main",
|
||||
},
|
||||
sender: { login: senderLogin, id: senderId },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a payload for an issue being labeled with the OpenHands label
|
||||
*/
|
||||
export function createIssueLabeledPayload(params: {
|
||||
installationId?: number;
|
||||
issueNumber?: number;
|
||||
issueTitle?: string;
|
||||
issueBody?: string;
|
||||
labelName?: string;
|
||||
repositoryName?: string;
|
||||
repositoryOwner?: string;
|
||||
senderLogin?: string;
|
||||
senderId?: number;
|
||||
}): IssuePayload {
|
||||
const {
|
||||
issueNumber = 1,
|
||||
issueTitle = "Test Issue for OpenHands Resolver",
|
||||
issueBody = "This is a test issue. Please add a README file.",
|
||||
labelName = "openhands",
|
||||
senderLogin = "test-user",
|
||||
senderId = 2000,
|
||||
...rest
|
||||
} = params;
|
||||
|
||||
const base = createBasePayload({ senderLogin, senderId, ...rest });
|
||||
const label = { name: labelName, id: Date.now() };
|
||||
|
||||
return {
|
||||
...base,
|
||||
action: "labeled",
|
||||
issue: {
|
||||
number: issueNumber,
|
||||
title: issueTitle,
|
||||
body: issueBody,
|
||||
state: "open",
|
||||
labels: [label],
|
||||
user: { login: senderLogin, id: senderId },
|
||||
},
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a payload for an issue comment mentioning @openhands
|
||||
*/
|
||||
export function createIssueCommentPayload(params: {
|
||||
installationId?: number;
|
||||
issueNumber?: number;
|
||||
issueTitle?: string;
|
||||
issueBody?: string;
|
||||
commentBody?: string;
|
||||
commentId?: number;
|
||||
repositoryName?: string;
|
||||
repositoryOwner?: string;
|
||||
senderLogin?: string;
|
||||
senderId?: number;
|
||||
isPullRequest?: boolean;
|
||||
}): IssueCommentPayload {
|
||||
const {
|
||||
issueNumber = 1,
|
||||
issueTitle = "Test Issue for OpenHands Resolver",
|
||||
issueBody = "This is a test issue.",
|
||||
commentBody = "@openhands please add a README file",
|
||||
commentId = 1001,
|
||||
senderLogin = "test-user",
|
||||
senderId = 2000,
|
||||
isPullRequest = false,
|
||||
...rest
|
||||
} = params;
|
||||
|
||||
const base = createBasePayload({ senderLogin, senderId, ...rest });
|
||||
|
||||
const payload: IssueCommentPayload = {
|
||||
...base,
|
||||
action: "created",
|
||||
issue: {
|
||||
number: issueNumber,
|
||||
title: issueTitle,
|
||||
body: issueBody,
|
||||
state: "open",
|
||||
labels: [],
|
||||
user: { login: "issue-creator", id: 3000 },
|
||||
},
|
||||
comment: {
|
||||
id: commentId,
|
||||
body: commentBody,
|
||||
user: { login: senderLogin, id: senderId },
|
||||
},
|
||||
};
|
||||
|
||||
if (isPullRequest) {
|
||||
payload.issue.pull_request = {
|
||||
url: `https://api.github.com/repos/${base.repository.full_name}/pulls/${issueNumber}`,
|
||||
};
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a payload for a PR review comment mentioning @openhands
|
||||
*/
|
||||
export function createPullRequestReviewCommentPayload(params: {
|
||||
installationId?: number;
|
||||
prNumber?: number;
|
||||
prTitle?: string;
|
||||
prBody?: string;
|
||||
commentBody?: string;
|
||||
commentId?: number;
|
||||
filePath?: string;
|
||||
lineNumber?: number;
|
||||
headBranch?: string;
|
||||
baseBranch?: string;
|
||||
repositoryName?: string;
|
||||
repositoryOwner?: string;
|
||||
senderLogin?: string;
|
||||
senderId?: number;
|
||||
}): PullRequestReviewCommentPayload {
|
||||
const {
|
||||
prNumber = 2,
|
||||
prTitle = "Test PR for OpenHands Resolver",
|
||||
prBody = "This is a test PR.",
|
||||
commentBody = "@openhands please fix this code",
|
||||
commentId = 2001,
|
||||
filePath = "src/main.ts",
|
||||
lineNumber = 10,
|
||||
headBranch = "feature-branch",
|
||||
baseBranch = "main",
|
||||
senderLogin = "test-user",
|
||||
senderId = 2000,
|
||||
...rest
|
||||
} = params;
|
||||
|
||||
const base = createBasePayload({ senderLogin, senderId, ...rest });
|
||||
|
||||
return {
|
||||
...base,
|
||||
action: "created",
|
||||
pull_request: {
|
||||
number: prNumber,
|
||||
title: prTitle,
|
||||
body: prBody,
|
||||
state: "open",
|
||||
head: { ref: headBranch, sha: "abc123def456" },
|
||||
base: { ref: baseBranch },
|
||||
user: { login: "pr-creator", id: 4000 },
|
||||
},
|
||||
comment: {
|
||||
id: commentId,
|
||||
node_id: `PRRC_${commentId}`,
|
||||
body: commentBody,
|
||||
path: filePath,
|
||||
line: lineNumber,
|
||||
user: { login: senderLogin, id: senderId },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the GitHub event type for a payload
|
||||
*/
|
||||
export function getEventType(payload: GitHubWebhookPayload): string {
|
||||
if ("comment" in payload && "pull_request" in payload) {
|
||||
return "pull_request_review_comment";
|
||||
}
|
||||
if ("comment" in payload) {
|
||||
return "issue_comment";
|
||||
}
|
||||
if ("issue" in payload) {
|
||||
return "issues";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Mock Server Exports
|
||||
*/
|
||||
|
||||
export * from "./github-webhook-payloads";
|
||||
export * from "./mock-github-client";
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Mock GitHub Client
|
||||
*
|
||||
* Client utilities for interacting with the mock GitHub server during tests.
|
||||
*/
|
||||
|
||||
import {
|
||||
createIssueLabeledPayload,
|
||||
createIssueCommentPayload,
|
||||
createPullRequestReviewCommentPayload,
|
||||
getEventType,
|
||||
GitHubWebhookPayload,
|
||||
} from "./github-webhook-payloads";
|
||||
|
||||
export interface MockGitHubClientConfig {
|
||||
mockServerUrl: string;
|
||||
webhookTargetUrl: string;
|
||||
}
|
||||
|
||||
export interface TriggerWebhookResult {
|
||||
status: string;
|
||||
targetUrl: string;
|
||||
eventType: string;
|
||||
responseStatus: number;
|
||||
responseBody: string;
|
||||
}
|
||||
|
||||
export interface WebhookEvent {
|
||||
action: string;
|
||||
payload: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface OutgoingResponse {
|
||||
body: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client for interacting with the Mock GitHub Server
|
||||
*/
|
||||
export class MockGitHubClient {
|
||||
private mockServerUrl: string;
|
||||
|
||||
private webhookTargetUrl: string;
|
||||
|
||||
constructor(config: MockGitHubClientConfig) {
|
||||
this.mockServerUrl = config.mockServerUrl;
|
||||
this.webhookTargetUrl = config.webhookTargetUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the mock server is healthy
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.mockServerUrl}/_health`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the mock server to be ready
|
||||
*/
|
||||
async waitForReady(timeoutMs = 30000): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
if (await this.healthCheck()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`Mock GitHub server not ready after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a webhook to the target URL
|
||||
*/
|
||||
async triggerWebhook(
|
||||
payload: GitHubWebhookPayload,
|
||||
): Promise<TriggerWebhookResult> {
|
||||
const eventType = getEventType(payload);
|
||||
|
||||
const response = await fetch(
|
||||
`${this.mockServerUrl}/_test/trigger-webhook`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
targetUrl: this.webhookTargetUrl,
|
||||
eventType,
|
||||
payload,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an issue labeled event (simulates adding the openhands label)
|
||||
*/
|
||||
async triggerIssueLabeledEvent(
|
||||
params?: Parameters<typeof createIssueLabeledPayload>[0],
|
||||
): Promise<TriggerWebhookResult> {
|
||||
const payload = createIssueLabeledPayload(params || {});
|
||||
return this.triggerWebhook(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an issue comment event (simulates @openhands mention in issue)
|
||||
*/
|
||||
async triggerIssueCommentEvent(
|
||||
params?: Parameters<typeof createIssueCommentPayload>[0],
|
||||
): Promise<TriggerWebhookResult> {
|
||||
const payload = createIssueCommentPayload(params || {});
|
||||
return this.triggerWebhook(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a PR review comment event (simulates @openhands mention in PR)
|
||||
*/
|
||||
async triggerPRReviewCommentEvent(
|
||||
params?: Parameters<typeof createPullRequestReviewCommentPayload>[0],
|
||||
): Promise<TriggerWebhookResult> {
|
||||
const payload = createPullRequestReviewCommentPayload(params || {});
|
||||
return this.triggerWebhook(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recorded webhook events
|
||||
*/
|
||||
async getWebhookEvents(): Promise<WebhookEvent[]> {
|
||||
const response = await fetch(`${this.mockServerUrl}/_test/webhook-events`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all outgoing responses (comments posted by the resolver)
|
||||
*/
|
||||
async getOutgoingResponses(): Promise<OutgoingResponse[]> {
|
||||
const response = await fetch(
|
||||
`${this.mockServerUrl}/_test/outgoing-responses`,
|
||||
);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all recorded events
|
||||
*/
|
||||
async clearEvents(): Promise<void> {
|
||||
await fetch(`${this.mockServerUrl}/_test/clear-events`, { method: "POST" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all mock data to initial state
|
||||
*/
|
||||
async reset(): Promise<void> {
|
||||
await fetch(`${this.mockServerUrl}/_test/reset`, { method: "POST" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the resolver to post a response
|
||||
* @param timeoutMs Maximum time to wait
|
||||
* @param expectedCount Number of responses to wait for (default: 1)
|
||||
* @param checkIntervalMs How often to check for responses
|
||||
*/
|
||||
async waitForResponse(
|
||||
timeoutMs = 120000,
|
||||
expectedCount = 1,
|
||||
checkIntervalMs = 2000,
|
||||
): Promise<OutgoingResponse[]> {
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
const responses = await this.getOutgoingResponses();
|
||||
if (responses.length >= expectedCount) {
|
||||
return responses;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, checkIntervalMs));
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for ${expectedCount} response(s) after ${timeoutMs}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a response containing specific text
|
||||
*/
|
||||
async waitForResponseContaining(
|
||||
expectedText: string,
|
||||
timeoutMs = 120000,
|
||||
checkIntervalMs = 2000,
|
||||
): Promise<OutgoingResponse> {
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
const responses = await this.getOutgoingResponses();
|
||||
for (const response of responses) {
|
||||
if (response.body.includes(expectedText)) {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, checkIntervalMs));
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for response containing "${expectedText}" after ${timeoutMs}ms`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a MockGitHubClient with default configuration
|
||||
*/
|
||||
export function createMockGitHubClient(
|
||||
mockServerPort = 9999,
|
||||
appPort = 12000,
|
||||
): MockGitHubClient {
|
||||
return new MockGitHubClient({
|
||||
mockServerUrl: `http://localhost:${mockServerPort}`,
|
||||
webhookTargetUrl: `http://localhost:${appPort}/api/integration/github/events`,
|
||||
});
|
||||
}
|
||||
Generated
+4565
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "openhands-integration-tests",
|
||||
"version": "1.0.0",
|
||||
"description": "Integration and smoke tests for OpenHands using Playwright",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:smoke": "playwright test --grep @smoke",
|
||||
"test:github-resolver": "playwright test --grep @github-resolver",
|
||||
"test:staging": "cross-env BASE_URL=https://staging.all-hands.dev playwright test",
|
||||
"test:production": "cross-env BASE_URL=https://app.all-hands.dev playwright test",
|
||||
"test:feature": "playwright test",
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:debug": "playwright test --debug",
|
||||
"test:ui": "playwright test --ui",
|
||||
"setup:auth": "playwright test --project=setup",
|
||||
"report": "playwright show-report",
|
||||
"codegen": "playwright codegen",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "npm run typecheck && eslint . --ext .ts && prettier --check \"**/*.ts\"",
|
||||
"lint:fix": "eslint . --ext .ts --fix && prettier --write \"**/*.ts\"",
|
||||
"mock:github": "tsx mocks/github-mock-server.ts",
|
||||
"mock:github:start": "tsx mocks/github-mock-server.ts &"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-unused-imports": "^3.2.0",
|
||||
"prettier": "^3.4.2",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Page, Locator, expect } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Base page object class that provides common functionality
|
||||
* for all page objects in the test suite.
|
||||
*/
|
||||
export class BasePage {
|
||||
readonly page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a specific path
|
||||
*/
|
||||
async goto(path: string = "/"): Promise<void> {
|
||||
await this.page.goto(path);
|
||||
await this.waitForPageLoad();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the page to fully load
|
||||
*/
|
||||
async waitForPageLoad(): Promise<void> {
|
||||
await this.page
|
||||
.waitForLoadState("networkidle", { timeout: 30_000 })
|
||||
.catch(() => {});
|
||||
await this.page.waitForLoadState("domcontentloaded");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an element to be visible
|
||||
*/
|
||||
async waitForElement(
|
||||
locator: Locator,
|
||||
timeout: number = 30_000,
|
||||
): Promise<void> {
|
||||
await expect(locator).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an element to be hidden
|
||||
*/
|
||||
async waitForElementHidden(
|
||||
locator: Locator,
|
||||
timeout: number = 30_000,
|
||||
): Promise<void> {
|
||||
await expect(locator).toBeHidden({ timeout });
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a screenshot with a descriptive name
|
||||
*/
|
||||
async screenshot(name: string): Promise<void> {
|
||||
await this.page.screenshot({
|
||||
path: `test-results/screenshots/${name}-${Date.now()}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error banner is visible
|
||||
*/
|
||||
async hasError(): Promise<boolean> {
|
||||
const errorBanner = this.page.getByTestId("error-message-banner");
|
||||
return errorBanner.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error message if error banner is present
|
||||
*/
|
||||
async getErrorMessage(): Promise<string | null> {
|
||||
const errorBanner = this.page.getByTestId("error-message-banner");
|
||||
if (await errorBanner.isVisible().catch(() => false)) {
|
||||
return errorBanner.textContent();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for network to be idle
|
||||
*/
|
||||
async waitForNetworkIdle(timeout: number = 10_000): Promise<void> {
|
||||
await this.page
|
||||
.waitForLoadState("networkidle", { timeout })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { Page, Locator, expect } from "@playwright/test";
|
||||
import { BasePage } from "./BasePage";
|
||||
|
||||
/**
|
||||
* Agent states that can be observed during conversation
|
||||
*/
|
||||
export enum AgentState {
|
||||
LOADING = "loading",
|
||||
RUNNING = "running",
|
||||
AWAITING_USER_INPUT = "awaiting_user_input",
|
||||
AWAITING_USER_CONFIRMATION = "awaiting_user_confirmation",
|
||||
FINISHED = "finished",
|
||||
ERROR = "error",
|
||||
PAUSED = "paused",
|
||||
STOPPED = "stopped",
|
||||
INIT = "init",
|
||||
}
|
||||
|
||||
/**
|
||||
* Page object for the Conversation/Chat interface where users
|
||||
* interact with the OpenHands agent.
|
||||
*/
|
||||
export class ConversationPage extends BasePage {
|
||||
// Main containers
|
||||
readonly appRoute: Locator;
|
||||
|
||||
readonly chatBox: Locator;
|
||||
|
||||
// Chat input elements
|
||||
readonly chatInput: Locator;
|
||||
|
||||
readonly sendButton: Locator;
|
||||
|
||||
readonly stopButton: Locator;
|
||||
|
||||
// Message elements
|
||||
readonly errorBanner: Locator;
|
||||
|
||||
readonly waitingMessage: Locator;
|
||||
|
||||
// Status indicators
|
||||
readonly statusIndicator: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
|
||||
this.appRoute = page.getByTestId("app-route");
|
||||
this.chatBox = page.getByTestId("interactive-chat-box");
|
||||
this.chatInput = page.getByTestId("chat-input");
|
||||
this.sendButton = page
|
||||
.locator(
|
||||
'button[type="submit"], button:has-text("Send"), [data-testid*="send"]',
|
||||
)
|
||||
.first();
|
||||
this.stopButton = page
|
||||
.locator('button:has-text("Stop"), [data-testid*="stop"]')
|
||||
.first();
|
||||
this.errorBanner = page.getByTestId("error-message-banner");
|
||||
this.waitingMessage = page.locator('[data-testid*="waiting"]').first();
|
||||
this.statusIndicator = page.getByTestId("status-icon");
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a specific conversation
|
||||
*/
|
||||
async gotoConversation(conversationId: string): Promise<void> {
|
||||
await super.goto(`/conversation/${conversationId}`);
|
||||
await this.waitForConversationReady();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for conversation interface to be ready for input
|
||||
*/
|
||||
async waitForConversationReady(timeout: number = 30_000): Promise<void> {
|
||||
// Wait for the chat interface to appear
|
||||
await expect(this.chatBox).toBeVisible({ timeout });
|
||||
|
||||
// Wait for the chat input to be visible
|
||||
await expect(this.chatInput).toBeVisible({ timeout });
|
||||
|
||||
// Wait for agent to be ready by checking for "Waiting for task" text
|
||||
// Note: Using text search since data-testid is not yet deployed to staging
|
||||
const waitingForTaskText = this.page.getByText("Waiting for task");
|
||||
await expect(waitingForTaskText).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the agent to be ready to receive input
|
||||
*/
|
||||
async waitForAgentReady(timeout: number = 90_000): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
// Check if there's an error
|
||||
if (await this.hasError()) {
|
||||
const errorMsg = await this.getErrorMessage();
|
||||
throw new Error(`Agent error: ${errorMsg}`);
|
||||
}
|
||||
|
||||
// Check if input is enabled (agent is ready)
|
||||
const isInputEnabled = await this.isChatInputEnabled();
|
||||
if (isInputEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait a bit before checking again
|
||||
await this.page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
throw new Error(`Agent not ready within ${timeout}ms timeout`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the chat input is enabled
|
||||
*/
|
||||
async isChatInputEnabled(): Promise<boolean> {
|
||||
try {
|
||||
// contentEditable divs don't have a disabled state, check for pointer-events or class
|
||||
const isVisible = await this.chatInput.isVisible();
|
||||
if (!isVisible) return false;
|
||||
|
||||
// Check if there's a loading state or disabled class
|
||||
const classes = await this.chatInput.getAttribute("class");
|
||||
if (classes?.includes("disabled") || classes?.includes("loading")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the agent
|
||||
*/
|
||||
async sendMessage(message: string): Promise<void> {
|
||||
// Wait for input to be ready
|
||||
await expect(this.chatInput).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Clear any existing content and type the message
|
||||
await this.chatInput.click();
|
||||
await this.chatInput.fill("");
|
||||
await this.page.keyboard.type(message);
|
||||
|
||||
// Submit the message
|
||||
await this.page.keyboard.press("Enter");
|
||||
|
||||
// Small delay to ensure message is sent
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for agent to respond (agent starts processing)
|
||||
*/
|
||||
async waitForAgentProcessing(timeout: number = 10_000): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
// Check if agent is processing (input disabled or loading indicator visible)
|
||||
const isProcessing = await this.isAgentProcessing();
|
||||
if (isProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// It's okay if we don't see processing state - agent might have already finished
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if agent is currently processing
|
||||
*/
|
||||
async isAgentProcessing(): Promise<boolean> {
|
||||
// Check for loading indicators or disabled input
|
||||
const loadingIndicator = this.page
|
||||
.locator(
|
||||
'[data-testid*="loading"], [class*="loading"], [class*="spinner"]',
|
||||
)
|
||||
.first();
|
||||
if (
|
||||
await loadingIndicator.isVisible({ timeout: 1_000 }).catch(() => false)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if input is disabled (indicates processing)
|
||||
const isInputEnabled = await this.isChatInputEnabled();
|
||||
return !isInputEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for agent to complete processing and return to ready state
|
||||
*/
|
||||
async waitForAgentComplete(timeout: number = 120_000): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// First, wait for processing to start
|
||||
await this.waitForAgentProcessing(10_000).catch(() => {});
|
||||
|
||||
// Then wait for processing to complete
|
||||
while (Date.now() - startTime < timeout) {
|
||||
// Check for errors
|
||||
if (await this.hasError()) {
|
||||
const errorMsg = await this.getErrorMessage();
|
||||
throw new Error(`Agent error during processing: ${errorMsg}`);
|
||||
}
|
||||
|
||||
// Check if agent is back to ready state
|
||||
const isInputEnabled = await this.isChatInputEnabled();
|
||||
if (isInputEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
throw new Error(`Agent did not complete within ${timeout}ms timeout`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all visible messages in the chat
|
||||
*/
|
||||
async getMessages(): Promise<string[]> {
|
||||
const messageElements = this.page.locator(
|
||||
'[data-testid*="message"], [class*="message"]',
|
||||
);
|
||||
return messageElements.allTextContents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last message from the agent
|
||||
*/
|
||||
async getLastAgentMessage(): Promise<string | null> {
|
||||
const messages = await this.getMessages();
|
||||
// Return the last message that's likely from the agent
|
||||
return messages.length > 0 ? messages[messages.length - 1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a message containing specific text to appear
|
||||
* @param expectedText - The text to search for in messages
|
||||
* @param timeout - Maximum time to wait in milliseconds
|
||||
* @returns The message containing the expected text
|
||||
*/
|
||||
async waitForMessageContaining(
|
||||
expectedText: string,
|
||||
timeout: number = 120_000,
|
||||
): Promise<string> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
// Check for errors first
|
||||
if (await this.hasError()) {
|
||||
const errorMsg = await this.getErrorMessage();
|
||||
throw new Error(`Agent error while waiting for message: ${errorMsg}`);
|
||||
}
|
||||
|
||||
// Get all messages and check if any contain the expected text
|
||||
const messages = await this.getMessages();
|
||||
for (const message of messages) {
|
||||
if (message.includes(expectedText)) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait a bit before checking again
|
||||
await this.page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// Get all messages for error reporting
|
||||
const allMessages = await this.getMessages();
|
||||
throw new Error(
|
||||
`Timeout waiting for message containing "${expectedText}" after ${timeout}ms. ` +
|
||||
`Messages found: ${JSON.stringify(allMessages.slice(-5))}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the currently running agent
|
||||
*/
|
||||
async stopAgent(): Promise<void> {
|
||||
if (
|
||||
await this.stopButton.isVisible({ timeout: 2_000 }).catch(() => false)
|
||||
) {
|
||||
await this.stopButton.click();
|
||||
await this.page.waitForTimeout(1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify no error messages are displayed
|
||||
*/
|
||||
async verifyNoErrors(): Promise<void> {
|
||||
const hasError = await this.hasError();
|
||||
if (hasError) {
|
||||
const errorMsg = await this.getErrorMessage();
|
||||
throw new Error(`Unexpected error message: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a complete conversation flow:
|
||||
* 1. Wait for agent to be ready
|
||||
* 2. Send message
|
||||
* 3. Wait for completion
|
||||
* 4. Verify no errors
|
||||
*/
|
||||
async executePrompt(
|
||||
message: string,
|
||||
timeout: number = 120_000,
|
||||
): Promise<void> {
|
||||
// Ensure agent is ready
|
||||
await this.waitForAgentReady(30_000);
|
||||
|
||||
// Send the message
|
||||
await this.sendMessage(message);
|
||||
|
||||
// Wait for completion
|
||||
await this.waitForAgentComplete(timeout);
|
||||
|
||||
// Verify no errors
|
||||
await this.verifyNoErrors();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Page, Locator, expect } from "@playwright/test";
|
||||
import { BasePage } from "./BasePage";
|
||||
|
||||
/**
|
||||
* Page object for the Home screen where users start new conversations
|
||||
* and view recent conversations.
|
||||
*/
|
||||
export class HomePage extends BasePage {
|
||||
// Main containers
|
||||
readonly homeScreen: Locator;
|
||||
|
||||
readonly newConversationSection: Locator;
|
||||
|
||||
readonly recentConversationsSection: Locator;
|
||||
|
||||
// User avatar and menu
|
||||
readonly userAvatar: Locator;
|
||||
|
||||
readonly accountSettingsMenu: Locator;
|
||||
|
||||
// Repository selection
|
||||
readonly repoSelector: Locator;
|
||||
|
||||
readonly repoSearchInput: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
|
||||
this.homeScreen = page.getByTestId("home-screen");
|
||||
this.newConversationSection = page.getByTestId(
|
||||
"home-screen-new-conversation-section",
|
||||
);
|
||||
this.recentConversationsSection = page.getByTestId(
|
||||
"home-screen-recent-conversations-section",
|
||||
);
|
||||
this.userAvatar = page.getByTestId("user-avatar");
|
||||
this.accountSettingsMenu = page.getByTestId(
|
||||
"account-settings-context-menu",
|
||||
);
|
||||
this.repoSelector = page.locator('[data-testid*="repo"]').first();
|
||||
this.repoSearchInput = page
|
||||
.locator('input[placeholder*="repository"], input[placeholder*="repo"]')
|
||||
.first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the home page
|
||||
*/
|
||||
async goto(): Promise<void> {
|
||||
await super.goto("/");
|
||||
await this.waitForHomeScreen();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the home screen to be fully loaded
|
||||
*/
|
||||
async waitForHomeScreen(): Promise<void> {
|
||||
await expect(this.homeScreen).toBeVisible({ timeout: 30_000 });
|
||||
await this.waitForNetworkIdle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is logged in by verifying home screen is visible
|
||||
*/
|
||||
async isLoggedIn(): Promise<boolean> {
|
||||
try {
|
||||
await expect(this.homeScreen).toBeVisible({ timeout: 10_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a repository by searching for it
|
||||
* @param repoUrl - Full repository URL (e.g., https://github.com/OpenHands/deploy)
|
||||
*/
|
||||
async selectRepository(repoUrl: string): Promise<void> {
|
||||
// Extract repo name from URL
|
||||
const repoName = repoUrl.split("/").slice(-2).join("/");
|
||||
|
||||
// Look for repository selector/input
|
||||
const repoInput = this.page
|
||||
.locator('input[placeholder*="repository"], input[placeholder*="search"]')
|
||||
.first();
|
||||
const repoSelector = this.page
|
||||
.locator('[class*="repo"], [data-testid*="repo"]')
|
||||
.first();
|
||||
|
||||
// Try to find and interact with repo selection
|
||||
if (await repoInput.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||
await repoInput.fill(repoName);
|
||||
await this.page.waitForTimeout(1000); // Wait for search results
|
||||
} else if (
|
||||
await repoSelector.isVisible({ timeout: 5_000 }).catch(() => false)
|
||||
) {
|
||||
await repoSelector.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Click on the repository in the dropdown/list
|
||||
const repoOption = this.page.locator(`text=${repoName}`).first();
|
||||
if (await repoOption.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||
await repoOption.click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new conversation
|
||||
* @param buttonId - Optional test ID of the button to click (default: 'launch-new-conversation-button')
|
||||
*/
|
||||
async startNewConversation(
|
||||
buttonId: string = "launch-new-conversation-button",
|
||||
): Promise<void> {
|
||||
const startButton = this.page.getByTestId(buttonId);
|
||||
if (await startButton.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||
await startButton.click();
|
||||
}
|
||||
|
||||
// Wait for conversation/chat interface to load
|
||||
await this.page
|
||||
.waitForURL(/conversation|chat|app/, { timeout: 30_000 })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open user settings menu
|
||||
*
|
||||
* Note: The menu is conditionally rendered based on async state (config loaded,
|
||||
* user authenticated, etc.). We need to wait for the menu element to be attached
|
||||
* to the DOM before we can interact with it. The menu appears on hover over the
|
||||
* user-actions container, or when clicking the avatar toggles state.
|
||||
*/
|
||||
async openUserMenu(): Promise<void> {
|
||||
// First, wait for the user avatar to be visible
|
||||
await expect(this.userAvatar).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Wait for the menu to be attached to the DOM (may not be visible yet)
|
||||
// This ensures the async config/auth state has loaded
|
||||
await this.accountSettingsMenu.waitFor({
|
||||
state: "attached",
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Now hover over the user-actions container to trigger the menu visibility
|
||||
// The menu uses CSS group-hover to show, so we need to hover the parent
|
||||
const userActionsContainer = this.page.getByTestId("user-actions");
|
||||
await userActionsContainer.hover();
|
||||
|
||||
// Wait for the menu to become visible
|
||||
await expect(this.accountSettingsMenu).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of recent conversations
|
||||
*/
|
||||
async getRecentConversations(): Promise<string[]> {
|
||||
await this.waitForElement(this.recentConversationsSection);
|
||||
const conversations = await this.recentConversationsSection
|
||||
.locator("a, button, [role='button']")
|
||||
.allTextContents();
|
||||
return conversations.filter((text) => text.trim().length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click on the first conversation in the recent conversations list
|
||||
* The conversations are displayed as links in the recent-conversations section
|
||||
*/
|
||||
async clickFirstConversation(): Promise<void> {
|
||||
// Wait for recent conversations section to be visible
|
||||
const recentConversations = this.page.getByTestId("recent-conversations");
|
||||
await expect(recentConversations).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Find the first conversation link (they link to /conversations/{id})
|
||||
const firstConversationLink = recentConversations
|
||||
.locator('a[href^="/conversations/"]')
|
||||
.first();
|
||||
await expect(firstConversationLink).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Click the conversation
|
||||
await firstConversationLink.click();
|
||||
|
||||
// Wait for navigation to conversation page
|
||||
await this.page.waitForURL(/\/conversations\//, { timeout: 30_000 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { BasePage } from "./BasePage";
|
||||
export { HomePage } from "./HomePage";
|
||||
export { ConversationPage, AgentState } from "./ConversationPage";
|
||||
@@ -0,0 +1,151 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import dotenv from "dotenv";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
// Load environment variables from .env file
|
||||
dotenv.config({ path: path.resolve(import.meta.dirname, ".env") });
|
||||
|
||||
// Check if auth file exists (will be created by setup project)
|
||||
const authFile = path.resolve(import.meta.dirname, "./fixtures/auth.json");
|
||||
const hasAuthFile = fs.existsSync(authFile);
|
||||
|
||||
/**
|
||||
* Environment URLs for different deployment targets
|
||||
*/
|
||||
const environments = {
|
||||
staging: "https://staging.all-hands.dev",
|
||||
production: "https://app.all-hands.dev",
|
||||
local: "http://localhost:3000",
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the base URL from environment variable or default to staging
|
||||
* For feature branches, use: https://<feature_branch_name>.staging.all-hands.dev
|
||||
*/
|
||||
function getBaseURL(): string {
|
||||
const envUrl = process.env.BASE_URL;
|
||||
if (envUrl) {
|
||||
return envUrl;
|
||||
}
|
||||
|
||||
const env = process.env.TEST_ENV || "staging";
|
||||
return environments[env as keyof typeof environments] || environments.staging;
|
||||
}
|
||||
|
||||
/**
|
||||
* Playwright configuration for OpenHands integration tests
|
||||
*
|
||||
* Supports multiple environments:
|
||||
* - staging: https://staging.all-hands.dev
|
||||
* - production: https://app.all-hands.dev
|
||||
* - feature branches: https://<branch>.staging.all-hands.dev
|
||||
*
|
||||
* Usage:
|
||||
* - npm run test # Run all tests against staging
|
||||
* - npm run test:staging # Run all tests against staging
|
||||
* - npm run test:production # Run all tests against production
|
||||
* - BASE_URL=https://my-branch.staging.all-hands.dev npm test # Feature branch
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
|
||||
// Run tests in parallel
|
||||
fullyParallel: false, // Disabled for smoke tests to ensure sequential execution
|
||||
|
||||
// Fail the build on CI if you accidentally left test.only in the source code
|
||||
forbidOnly: !!process.env.CI,
|
||||
|
||||
// Retry failed tests (more retries in CI)
|
||||
retries: process.env.CI ? 2 : 1,
|
||||
|
||||
// Limit parallel workers (smoke tests should run sequentially)
|
||||
workers: process.env.CI ? 1 : 1,
|
||||
|
||||
// Reporter configuration
|
||||
reporter: process.env.CI
|
||||
? [["html", { outputFolder: "playwright-report" }], ["list"], ["github"]]
|
||||
: [["html", { outputFolder: "playwright-report" }], ["list"]],
|
||||
|
||||
// Timeout configuration
|
||||
timeout: 120_000, // 2 minutes per test (agent operations can be slow)
|
||||
expect: {
|
||||
timeout: 30_000, // 30 seconds for assertions
|
||||
},
|
||||
|
||||
// Shared settings for all projects
|
||||
use: {
|
||||
// Base URL for navigation
|
||||
baseURL: getBaseURL(),
|
||||
|
||||
// Collect trace on failure
|
||||
trace: "on-first-retry",
|
||||
|
||||
// Screenshots on failure
|
||||
screenshot: "only-on-failure",
|
||||
|
||||
// Video recording (useful for debugging CI failures)
|
||||
video: process.env.CI ? "on-first-retry" : "off",
|
||||
|
||||
// Ignore SSL errors (for staging/development environments)
|
||||
ignoreHTTPSErrors: true,
|
||||
|
||||
// Use persisted authentication state only if it exists
|
||||
storageState: hasAuthFile ? authFile : undefined,
|
||||
|
||||
// Browser viewport
|
||||
viewport: { width: 1280, height: 720 },
|
||||
|
||||
// Action timeout
|
||||
actionTimeout: 15_000,
|
||||
|
||||
// Navigation timeout
|
||||
navigationTimeout: 30_000,
|
||||
},
|
||||
|
||||
// Define test projects
|
||||
projects: [
|
||||
// Setup project - handles authentication
|
||||
{
|
||||
name: "setup",
|
||||
testMatch: /global-setup\.ts/,
|
||||
use: {
|
||||
storageState: undefined, // Don't use existing auth for setup
|
||||
},
|
||||
},
|
||||
|
||||
// Chromium tests (primary browser)
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
|
||||
// Firefox tests (optional - run with --project=firefox)
|
||||
{
|
||||
name: "firefox",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
},
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
|
||||
// WebKit tests (optional - run with --project=webkit)
|
||||
{
|
||||
name: "webkit",
|
||||
use: {
|
||||
...devices["Desktop Safari"],
|
||||
},
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
],
|
||||
|
||||
// Output directory for test artifacts
|
||||
outputDir: "./test-results",
|
||||
|
||||
// Global setup/teardown
|
||||
globalSetup: undefined, // We use a setup project instead for better parallelization
|
||||
globalTeardown: undefined,
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { ChildProcess, spawn } from "child_process";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
MockGitHubClient,
|
||||
createMockGitHubClient,
|
||||
createIssueLabeledPayload,
|
||||
} from "../mocks";
|
||||
import { ConversationPage, HomePage } from "../pages";
|
||||
|
||||
/**
|
||||
* GitHub Resolver Integration Tests
|
||||
*
|
||||
* These tests verify the GitHub resolver integration in two modes:
|
||||
*
|
||||
* ## Mode 1: Mock Server Tests (for local development)
|
||||
* Uses a local mock GitHub server to test the full webhook flow.
|
||||
* Requires:
|
||||
* - OpenHands running locally with GITHUB_APP_WEBHOOK_SECRET=test-webhook-secret
|
||||
* - The app configured to use the mock server for GitHub API calls
|
||||
*
|
||||
* ## Mode 2: Live Environment Tests (for staging/production)
|
||||
* Tests against real deployed environments using the real GitHub API.
|
||||
* Requires:
|
||||
* - GITHUB_TEST_USERNAME and GITHUB_TEST_PASSWORD for authentication
|
||||
* - The webhook endpoint to be accessible
|
||||
*
|
||||
* Environment Variables:
|
||||
* - USE_MOCK_GITHUB: Set to "true" to use mock server mode
|
||||
* - MOCK_GITHUB_PORT: Port for the mock GitHub server (default: 9999)
|
||||
* - APP_PORT: Port where the OpenHands app is running (default: 12000)
|
||||
* - GITHUB_APP_WEBHOOK_SECRET: Webhook secret for local testing
|
||||
*
|
||||
* Tags:
|
||||
* - @github-resolver: GitHub resolver integration tests
|
||||
* - @enterprise: Tests requiring enterprise features
|
||||
*/
|
||||
|
||||
// Configuration
|
||||
const USE_MOCK_GITHUB = process.env.USE_MOCK_GITHUB === "true";
|
||||
const MOCK_GITHUB_PORT = parseInt(process.env.MOCK_GITHUB_PORT || "9999", 10);
|
||||
const APP_PORT = parseInt(process.env.APP_PORT || "12000", 10);
|
||||
const MOCK_SERVER_STARTUP_TIMEOUT = 30_000;
|
||||
const RESOLVER_RESPONSE_TIMEOUT = 180_000;
|
||||
|
||||
// Mock server process
|
||||
let mockServerProcess: ChildProcess | null = null;
|
||||
let mockClient: MockGitHubClient | null = null;
|
||||
|
||||
/**
|
||||
* Generate webhook signature for testing
|
||||
*/
|
||||
function generateWebhookSignature(payload: string, secret: string): string {
|
||||
const hmac = crypto.createHmac("sha256", secret);
|
||||
hmac.update(payload);
|
||||
return `sha256=${hmac.digest("hex")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the mock GitHub server as a background process
|
||||
*/
|
||||
async function startMockServer(): Promise<void> {
|
||||
if (!USE_MOCK_GITHUB) return;
|
||||
|
||||
const serverPath = path.join(
|
||||
import.meta.dirname,
|
||||
"../mocks/github-mock-server.ts",
|
||||
);
|
||||
|
||||
console.log(`Starting mock GitHub server on port ${MOCK_GITHUB_PORT}...`);
|
||||
|
||||
mockServerProcess = spawn("npx", ["tsx", serverPath], {
|
||||
env: {
|
||||
...process.env,
|
||||
MOCK_GITHUB_PORT: String(MOCK_GITHUB_PORT),
|
||||
MOCK_GITHUB_WEBHOOK_SECRET:
|
||||
process.env.GITHUB_APP_WEBHOOK_SECRET || "test-webhook-secret",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
mockServerProcess.stdout?.on("data", (data) => {
|
||||
console.log(`[Mock GitHub] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
mockServerProcess.stderr?.on("data", (data) => {
|
||||
console.error(`[Mock GitHub ERROR] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
mockServerProcess.on("error", (error) => {
|
||||
console.error(`[Mock GitHub] Failed to start server: ${error.message}`);
|
||||
});
|
||||
|
||||
mockServerProcess.on("exit", (code) => {
|
||||
console.log(`[Mock GitHub] Server exited with code ${code}`);
|
||||
});
|
||||
|
||||
mockClient = createMockGitHubClient(MOCK_GITHUB_PORT, APP_PORT);
|
||||
await mockClient.waitForReady(MOCK_SERVER_STARTUP_TIMEOUT);
|
||||
console.log("Mock GitHub server is ready");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the mock GitHub server
|
||||
*/
|
||||
async function stopMockServer(): Promise<void> {
|
||||
if (mockServerProcess) {
|
||||
console.log("Stopping mock GitHub server...");
|
||||
mockServerProcess.kill("SIGTERM");
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
mockServerProcess?.kill("SIGKILL");
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
mockServerProcess?.on("exit", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
mockServerProcess = null;
|
||||
console.log("Mock GitHub server stopped");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SERVER TESTS (for local development with mock GitHub)
|
||||
// ============================================================================
|
||||
|
||||
test.describe("GitHub Resolver - Mock Server @github-resolver @enterprise @mock", () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
// Skip this entire suite unless USE_MOCK_GITHUB is true
|
||||
test.skip(!USE_MOCK_GITHUB, "Requires USE_MOCK_GITHUB=true");
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await startMockServer();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
if (mockClient) {
|
||||
await mockClient.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("mock server should be healthy", async () => {
|
||||
expect(mockClient).not.toBeNull();
|
||||
const isHealthy = await mockClient!.healthCheck();
|
||||
expect(isHealthy).toBe(true);
|
||||
});
|
||||
|
||||
test("should process issue labeled webhook and create conversation", async ({
|
||||
page,
|
||||
baseURL,
|
||||
}) => {
|
||||
expect(mockClient).not.toBeNull();
|
||||
|
||||
console.log("Triggering issue labeled webhook...");
|
||||
const webhookResult = await mockClient!.triggerIssueLabeledEvent({
|
||||
issueTitle: "Add README file",
|
||||
issueBody: "Please add a README.md file with project documentation.",
|
||||
labelName: "openhands",
|
||||
});
|
||||
|
||||
console.log(`Webhook response: ${JSON.stringify(webhookResult)}`);
|
||||
expect(webhookResult.responseStatus).toBe(200);
|
||||
|
||||
console.log("Waiting for resolver response...");
|
||||
const response = await mockClient!.waitForResponseContaining(
|
||||
"I'm on it",
|
||||
RESOLVER_RESPONSE_TIMEOUT,
|
||||
);
|
||||
|
||||
console.log(`Resolver response: ${response.body}`);
|
||||
expect(response.body).toContain("I'm on it");
|
||||
expect(response.body).toContain("track my progress");
|
||||
|
||||
const conversationLinkMatch = response.body.match(
|
||||
/conversations\/([a-f0-9]+)/,
|
||||
);
|
||||
expect(conversationLinkMatch).not.toBeNull();
|
||||
|
||||
const conversationId = conversationLinkMatch![1];
|
||||
console.log(`Conversation ID: ${conversationId}`);
|
||||
|
||||
const conversationPage = new ConversationPage(page);
|
||||
await page.goto(`${baseURL}/conversations/${conversationId}`);
|
||||
await conversationPage.waitForConversationReady(30_000);
|
||||
await expect(conversationPage.chatBox).toBeVisible();
|
||||
|
||||
await page.screenshot({
|
||||
path: "test-results/screenshots/github-resolver-conversation.png",
|
||||
});
|
||||
|
||||
console.log("Issue labeled webhook test passed");
|
||||
});
|
||||
|
||||
test("should process issue comment webhook with @openhands mention", async ({
|
||||
page,
|
||||
baseURL,
|
||||
}) => {
|
||||
expect(mockClient).not.toBeNull();
|
||||
|
||||
console.log("Triggering issue comment webhook...");
|
||||
const webhookResult = await mockClient!.triggerIssueCommentEvent({
|
||||
issueTitle: "Bug: Application crashes on startup",
|
||||
issueBody: "The application crashes when I try to start it.",
|
||||
commentBody: "@openhands please investigate this crash and fix it",
|
||||
});
|
||||
|
||||
console.log(`Webhook response: ${JSON.stringify(webhookResult)}`);
|
||||
expect(webhookResult.responseStatus).toBe(200);
|
||||
|
||||
console.log("Waiting for resolver response...");
|
||||
const response = await mockClient!.waitForResponseContaining(
|
||||
"I'm on it",
|
||||
RESOLVER_RESPONSE_TIMEOUT,
|
||||
);
|
||||
|
||||
console.log(`Resolver response: ${response.body}`);
|
||||
expect(response.body).toContain("I'm on it");
|
||||
|
||||
const conversationLinkMatch = response.body.match(
|
||||
/conversations\/([a-f0-9]+)/,
|
||||
);
|
||||
expect(conversationLinkMatch).not.toBeNull();
|
||||
|
||||
const conversationId = conversationLinkMatch![1];
|
||||
const conversationPage = new ConversationPage(page);
|
||||
await page.goto(`${baseURL}/conversations/${conversationId}`);
|
||||
await conversationPage.waitForConversationReady(30_000);
|
||||
|
||||
await page.screenshot({
|
||||
path: "test-results/screenshots/github-resolver-issue-comment.png",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// LIVE ENVIRONMENT TESTS (for staging/production with real GitHub)
|
||||
// ============================================================================
|
||||
|
||||
test.describe("GitHub Resolver - Live Environment @github-resolver @enterprise @live", () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
let homePage: HomePage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
homePage = new HomePage(page);
|
||||
});
|
||||
|
||||
test("should verify resolver conversations appear in conversation list", async ({
|
||||
page,
|
||||
}) => {
|
||||
/**
|
||||
* This test verifies that resolver-triggered conversations appear in the
|
||||
* user's conversation list. It checks the infrastructure is working by
|
||||
* looking at existing conversations.
|
||||
*/
|
||||
|
||||
// Navigate to home page (requires authentication via global-setup)
|
||||
await homePage.goto();
|
||||
await expect(homePage.homeScreen).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Look for recent conversations
|
||||
const recentConversations = page.getByTestId("recent-conversations");
|
||||
await expect(recentConversations).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const conversationLinks = recentConversations.locator(
|
||||
'a[href^="/conversations/"]',
|
||||
);
|
||||
const count = await conversationLinks.count();
|
||||
|
||||
console.log(`Found ${count} recent conversations`);
|
||||
|
||||
await page.screenshot({
|
||||
path: "test-results/screenshots/resolver-conversations-list.png",
|
||||
});
|
||||
|
||||
if (count > 0) {
|
||||
const firstConversation = conversationLinks.first();
|
||||
await firstConversation.click();
|
||||
|
||||
const conversationPage = new ConversationPage(page);
|
||||
await conversationPage.waitForConversationReady(30_000);
|
||||
|
||||
await page.screenshot({
|
||||
path: "test-results/screenshots/resolver-conversation-detail.png",
|
||||
});
|
||||
|
||||
console.log("Successfully navigated to a conversation");
|
||||
}
|
||||
});
|
||||
|
||||
test("should be able to send webhook with valid signature format", async ({
|
||||
baseURL,
|
||||
request,
|
||||
}) => {
|
||||
/**
|
||||
* This test verifies the webhook endpoint exists and validates signatures.
|
||||
* We send a properly formatted but invalid webhook to verify:
|
||||
* 1. The endpoint exists
|
||||
* 2. Signature verification is working
|
||||
*/
|
||||
|
||||
const payload = createIssueLabeledPayload({
|
||||
issueTitle: "Test Issue",
|
||||
issueBody: "Test body for integration test",
|
||||
labelName: "openhands",
|
||||
});
|
||||
|
||||
const payloadString = JSON.stringify(payload);
|
||||
const signature = generateWebhookSignature(payloadString, "wrong-secret");
|
||||
|
||||
const response = await request.post(
|
||||
`${baseURL}/api/integration/github/events`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-GitHub-Event": "issues",
|
||||
"X-Hub-Signature-256": signature,
|
||||
"X-GitHub-Delivery": crypto.randomUUID(),
|
||||
},
|
||||
data: payload,
|
||||
},
|
||||
);
|
||||
|
||||
console.log(`Webhook response status: ${response.status()}`);
|
||||
|
||||
// Either 403 (signature invalid) or 200 (if webhooks disabled) is acceptable
|
||||
expect([200, 403]).toContain(response.status());
|
||||
|
||||
const responseText = await response.text();
|
||||
console.log(`Webhook response: ${responseText}`);
|
||||
|
||||
if (response.status() === 403) {
|
||||
console.log(
|
||||
"Webhook signature validation is working (403 = invalid signature)",
|
||||
);
|
||||
} else if (response.status() === 200) {
|
||||
const body = JSON.parse(responseText);
|
||||
if (body.message?.includes("disabled")) {
|
||||
console.log("GitHub webhooks are disabled on this environment");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ERROR HANDLING TESTS
|
||||
// ============================================================================
|
||||
|
||||
test.describe("GitHub Resolver - Error Handling @github-resolver @enterprise", () => {
|
||||
test("should reject webhook without signature header", async ({
|
||||
baseURL,
|
||||
request,
|
||||
}) => {
|
||||
const payload = { action: "labeled", installation: { id: 12345 } };
|
||||
|
||||
const response = await request.post(
|
||||
`${baseURL}/api/integration/github/events`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-GitHub-Event": "issues",
|
||||
},
|
||||
data: payload,
|
||||
},
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Response status: ${response.status()} (expected 403 or 200 if disabled)`,
|
||||
);
|
||||
expect([200, 403]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("should handle malformed JSON gracefully", async ({
|
||||
baseURL,
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.post(
|
||||
`${baseURL}/api/integration/github/events`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-GitHub-Event": "issues",
|
||||
"X-Hub-Signature-256": "sha256=invalid",
|
||||
},
|
||||
data: "not valid json{{{",
|
||||
},
|
||||
);
|
||||
|
||||
console.log(`Response status: ${response.status()}`);
|
||||
expect([400, 403, 422, 500]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import { test as setup, expect } from "@playwright/test";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
const authFile = path.join(import.meta.dirname, "../fixtures/auth.json");
|
||||
|
||||
/**
|
||||
* Global setup test that handles authentication.
|
||||
*
|
||||
* This test runs before all other tests and saves the authentication state
|
||||
* to a file that can be reused across test runs.
|
||||
*
|
||||
* Authentication Methods:
|
||||
* 1. GitHub OAuth (default) - Requires GITHUB_TEST_USERNAME and GITHUB_TEST_PASSWORD
|
||||
* 2. Keycloak - Can be customized via KEYCLOAK_* environment variables
|
||||
* 3. Pre-existing auth state - If fixtures/auth.json exists and is valid
|
||||
*
|
||||
* Environment Variables:
|
||||
* - AUTH_METHOD: "github" | "keycloak" | "skip" (default: "github")
|
||||
* - GITHUB_TEST_USERNAME: GitHub username for test account
|
||||
* - GITHUB_TEST_PASSWORD: GitHub password for test account
|
||||
* - GITHUB_TEST_TOTP_SECRET: (Optional) TOTP secret for 2FA
|
||||
* - KEYCLOAK_URL: Keycloak server URL
|
||||
* - KEYCLOAK_USERNAME: Keycloak test username
|
||||
* - KEYCLOAK_PASSWORD: Keycloak test password
|
||||
*/
|
||||
setup("authenticate", async ({ page, baseURL }) => {
|
||||
const authMethod = process.env.AUTH_METHOD || "github";
|
||||
|
||||
// Check if we should skip authentication (use existing auth state)
|
||||
if (authMethod === "skip") {
|
||||
if (fs.existsSync(authFile)) {
|
||||
console.log(
|
||||
"Using existing authentication state from fixtures/auth.json",
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
"AUTH_METHOD=skip but no existing auth.json found. Please run authentication first.",
|
||||
);
|
||||
}
|
||||
|
||||
// Navigate to the application
|
||||
await page.goto(baseURL || "/");
|
||||
|
||||
// Check if already authenticated
|
||||
const isAuthenticated = await checkIfAuthenticated(page);
|
||||
if (isAuthenticated) {
|
||||
console.log("Already authenticated, saving state...");
|
||||
await page.context().storageState({ path: authFile });
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform authentication based on method
|
||||
if (authMethod === "github") {
|
||||
await authenticateWithGitHub(page);
|
||||
} else if (authMethod === "keycloak") {
|
||||
await authenticateWithKeycloak(page);
|
||||
} else {
|
||||
throw new Error(`Unknown AUTH_METHOD: ${authMethod}`);
|
||||
}
|
||||
|
||||
// Wait for successful redirect back to app (could be home page or accept-tos)
|
||||
await page.waitForURL(
|
||||
(url) => {
|
||||
const urlString = url.toString();
|
||||
return (
|
||||
!urlString.includes("github.com") &&
|
||||
!urlString.includes("login") &&
|
||||
!urlString.includes("keycloak")
|
||||
);
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
|
||||
// Handle TOS acceptance if redirected to accept-tos page
|
||||
if (page.url().includes("/accept-tos")) {
|
||||
console.log(
|
||||
"Redirected to accept-tos page after authentication, handling TOS acceptance...",
|
||||
);
|
||||
await handleTOSAcceptance(page);
|
||||
}
|
||||
|
||||
// Verify authentication succeeded
|
||||
await expect(page.getByTestId("home-screen")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Save authentication state
|
||||
await page.context().storageState({ path: authFile });
|
||||
console.log("Authentication successful, state saved to fixtures/auth.json");
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if the user is already authenticated
|
||||
*/
|
||||
async function checkIfAuthenticated(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Look for elements that indicate authentication
|
||||
const homeScreen = page.getByTestId("home-screen");
|
||||
const loginPage = page.getByTestId("login-page");
|
||||
|
||||
// Wait a bit for the page to stabilize
|
||||
await page
|
||||
.waitForLoadState("networkidle", { timeout: 10_000 })
|
||||
.catch(() => {});
|
||||
|
||||
// Check if we're on the home screen (authenticated)
|
||||
const isOnHome = await homeScreen.isVisible().catch(() => false);
|
||||
const isOnLogin = await loginPage.isVisible().catch(() => false);
|
||||
|
||||
return isOnHome && !isOnLogin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using GitHub OAuth
|
||||
*/
|
||||
async function authenticateWithGitHub(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<void> {
|
||||
const username = process.env.GITHUB_TEST_USERNAME;
|
||||
const password = process.env.GITHUB_TEST_PASSWORD;
|
||||
|
||||
if (!username || !password) {
|
||||
throw new Error(
|
||||
"GitHub authentication requires GITHUB_TEST_USERNAME and GITHUB_TEST_PASSWORD environment variables",
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Starting GitHub authentication...");
|
||||
|
||||
// Click the GitHub login button
|
||||
const githubButton = page.getByRole("button", { name: "Log in with GitHub" });
|
||||
await expect(githubButton).toBeVisible({ timeout: 10_000 });
|
||||
await githubButton.click();
|
||||
|
||||
// Wait for redirect - could be GitHub.com, home page, or accept-tos
|
||||
// If user is already logged into Keycloak, they may be redirected back to the app
|
||||
await page.waitForURL(
|
||||
(url) => {
|
||||
const urlString = url.toString();
|
||||
return (
|
||||
urlString.includes("github.com") ||
|
||||
urlString.includes("/accept-tos") ||
|
||||
// Check if redirected back to home (no login/keycloak in URL)
|
||||
(!urlString.includes("keycloak") && !urlString.includes("/login"))
|
||||
);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
const currentUrl = page.url();
|
||||
|
||||
// If redirected to accept-tos, handle TOS acceptance
|
||||
if (currentUrl.includes("/accept-tos")) {
|
||||
console.log("Redirected to accept-tos page, handling TOS acceptance...");
|
||||
await handleTOSAcceptance(page);
|
||||
console.log("TOS acceptance completed");
|
||||
return;
|
||||
}
|
||||
|
||||
// If redirected to home page (already authenticated via Keycloak session)
|
||||
if (!currentUrl.includes("github.com")) {
|
||||
console.log("Already authenticated via Keycloak session");
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue with GitHub login flow
|
||||
// Fill in GitHub credentials
|
||||
const usernameField = page.locator('input[name="login"]');
|
||||
const passwordField = page.locator('input[name="password"]');
|
||||
|
||||
await usernameField.waitFor({ state: "visible", timeout: 10_000 });
|
||||
|
||||
await usernameField.fill(username);
|
||||
await passwordField.fill(password);
|
||||
|
||||
// Submit the form
|
||||
await page.locator('input[type="submit"][value="Sign in"]').click();
|
||||
|
||||
// Handle potential 2FA
|
||||
const totpSecret = process.env.GITHUB_TEST_TOTP_SECRET;
|
||||
if (totpSecret) {
|
||||
await handle2FA(page, totpSecret);
|
||||
}
|
||||
|
||||
// Handle OAuth authorization if needed
|
||||
await handleOAuthAuthorization(page);
|
||||
|
||||
console.log("GitHub authentication flow completed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Terms of Service acceptance flow
|
||||
*/
|
||||
async function handleTOSAcceptance(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<void> {
|
||||
// Wait for the TOS page to be fully loaded
|
||||
await page
|
||||
.waitForLoadState("networkidle", { timeout: 10_000 })
|
||||
.catch(() => {});
|
||||
|
||||
// Find and click the TOS checkbox
|
||||
const tosCheckbox = page.locator('input[type="checkbox"]');
|
||||
await tosCheckbox.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await tosCheckbox.click();
|
||||
|
||||
// Find and click the Continue button
|
||||
const continueButton = page.getByRole("button", { name: "Continue" });
|
||||
await expect(continueButton).toBeEnabled({ timeout: 5_000 });
|
||||
await continueButton.click();
|
||||
|
||||
// Wait for redirect to home page after TOS acceptance
|
||||
await page.waitForURL(
|
||||
(url) => {
|
||||
const urlString = url.toString();
|
||||
return !urlString.includes("/accept-tos");
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle GitHub 2FA if enabled
|
||||
*/
|
||||
async function handle2FA(
|
||||
page: import("@playwright/test").Page,
|
||||
totpSecret: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check if 2FA page appears
|
||||
const otpField = page.locator('input[name="otp"]');
|
||||
const isOtpVisible = await otpField
|
||||
.isVisible({ timeout: 5_000 })
|
||||
.catch(() => false);
|
||||
|
||||
if (isOtpVisible) {
|
||||
console.log("2FA required, generating TOTP code...");
|
||||
|
||||
// Generate TOTP code (you'd need to implement this or use a library)
|
||||
const totpCode = await generateTOTP(totpSecret);
|
||||
await otpField.fill(totpCode);
|
||||
|
||||
// Submit 2FA
|
||||
await page.locator('button[type="submit"]').click();
|
||||
}
|
||||
} catch {
|
||||
// 2FA not required, continue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate TOTP code from secret
|
||||
* Note: In production, use a proper TOTP library like 'otplib'
|
||||
*/
|
||||
async function generateTOTP(_secret: string): Promise<string> {
|
||||
// This is a placeholder - in production, use:
|
||||
// import { authenticator } from 'otplib';
|
||||
// return authenticator.generate(secret);
|
||||
throw new Error(
|
||||
"TOTP generation not implemented. Install 'otplib' package and implement generateTOTP function.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth authorization prompt if it appears
|
||||
*/
|
||||
async function handleOAuthAuthorization(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check if we need to authorize the app
|
||||
const authorizeButton = page.locator('button[name="authorize"]');
|
||||
const isAuthVisible = await authorizeButton
|
||||
.isVisible({ timeout: 5_000 })
|
||||
.catch(() => false);
|
||||
|
||||
if (isAuthVisible) {
|
||||
console.log("OAuth authorization required, clicking authorize...");
|
||||
await authorizeButton.click();
|
||||
}
|
||||
} catch {
|
||||
// No authorization needed, continue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using Keycloak
|
||||
*/
|
||||
async function authenticateWithKeycloak(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<void> {
|
||||
const username = process.env.KEYCLOAK_USERNAME;
|
||||
const password = process.env.KEYCLOAK_PASSWORD;
|
||||
|
||||
if (!username || !password) {
|
||||
throw new Error(
|
||||
"Keycloak authentication requires KEYCLOAK_USERNAME and KEYCLOAK_PASSWORD environment variables",
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Starting Keycloak authentication...");
|
||||
|
||||
// Navigate to login page and initiate Keycloak flow
|
||||
// The exact flow depends on your Keycloak configuration
|
||||
await page.goto("/login");
|
||||
|
||||
// Wait for Keycloak login page
|
||||
await page.waitForURL(/keycloak|auth/, { timeout: 30_000 });
|
||||
|
||||
// Fill in Keycloak credentials
|
||||
await page.locator("#username").fill(username);
|
||||
await page.locator("#password").fill(password);
|
||||
|
||||
// Submit
|
||||
await page.locator("#kc-login").click();
|
||||
|
||||
console.log("Keycloak authentication flow completed");
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { HomePage, ConversationPage } from "../pages";
|
||||
|
||||
/**
|
||||
* Smoke Tests for OpenHands Application
|
||||
*
|
||||
* These tests verify the critical path of the application:
|
||||
* 1. User can log in (handled by global-setup)
|
||||
* 2. User can access the home screen
|
||||
* 3. User can select a repository
|
||||
* 4. User can start a conversation
|
||||
* 5. Agent can process a simple prompt without errors
|
||||
*
|
||||
* Tags:
|
||||
* - @smoke: Core smoke tests that must pass
|
||||
* - @critical: Critical functionality tests
|
||||
*
|
||||
* Environment Variables:
|
||||
* - TEST_REPO_URL: Repository to use for testing (default: null)
|
||||
*/
|
||||
|
||||
// Test configuration
|
||||
const { TEST_REPO_URL } = process.env;
|
||||
|
||||
test.describe("Smoke Tests @smoke", () => {
|
||||
test.describe.configure({ mode: "serial" }); // Run tests in sequence
|
||||
|
||||
let homePage: HomePage;
|
||||
let conversationPage: ConversationPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
homePage = new HomePage(page);
|
||||
conversationPage = new ConversationPage(page);
|
||||
});
|
||||
|
||||
test("should display home screen after authentication @critical", async ({
|
||||
page,
|
||||
}) => {
|
||||
await homePage.goto();
|
||||
|
||||
// Verify home screen is visible
|
||||
await expect(homePage.homeScreen).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Verify key sections are present
|
||||
await expect(homePage.newConversationSection).toBeVisible();
|
||||
|
||||
// Take screenshot for verification
|
||||
await page.screenshot({ path: "test-results/screenshots/home-screen.png" });
|
||||
});
|
||||
|
||||
test("should have user avatar visible indicating logged in state @critical", async () => {
|
||||
await homePage.goto();
|
||||
|
||||
// Verify user is logged in
|
||||
const isLoggedIn = await homePage.isLoggedIn();
|
||||
expect(isLoggedIn).toBe(true);
|
||||
|
||||
// Verify user avatar is visible
|
||||
await expect(homePage.userAvatar).toBeVisible();
|
||||
});
|
||||
|
||||
test("should be able to open user menu", async () => {
|
||||
await homePage.goto();
|
||||
|
||||
// Open user menu
|
||||
await homePage.openUserMenu();
|
||||
|
||||
// Verify menu is visible
|
||||
await expect(homePage.accountSettingsMenu).toBeVisible();
|
||||
});
|
||||
|
||||
test("should be able to purchase $10 credits via Stripe @critical", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Navigate to home and open user menu
|
||||
await homePage.goto();
|
||||
await homePage.openUserMenu();
|
||||
|
||||
// Click on Billing link in the user menu
|
||||
const billingLink = page.getByRole("link", { name: /billing/i });
|
||||
await billingLink.click();
|
||||
|
||||
// Wait for billing page to load
|
||||
await page.waitForURL(/\/settings\/billing/, { timeout: 30_000 });
|
||||
await expect(page.getByTestId("billing-settings")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Capture initial balance
|
||||
const balanceElement = page.getByTestId("user-balance");
|
||||
await expect(balanceElement).toBeVisible({ timeout: 10_000 });
|
||||
const initialBalanceText = await balanceElement.textContent();
|
||||
const initialBalance = parseFloat(
|
||||
initialBalanceText?.replace("$", "") || "0",
|
||||
);
|
||||
console.log(`Initial balance: $${initialBalance.toFixed(2)}`);
|
||||
|
||||
// Enter $10 in the Add Funds input
|
||||
const topUpInput = page.getByTestId("top-up-input");
|
||||
await topUpInput.fill("10");
|
||||
|
||||
// Click Add Credit button
|
||||
const addCreditButton = page.getByRole("button", { name: /add credit/i });
|
||||
await expect(addCreditButton).toBeEnabled({ timeout: 5_000 });
|
||||
await addCreditButton.click();
|
||||
|
||||
// Wait for redirect to Stripe checkout
|
||||
await page.waitForURL(/checkout\.stripe\.com/, { timeout: 30_000 });
|
||||
console.log("Redirected to Stripe checkout");
|
||||
|
||||
// Wait for the Pay button to be present (indicates form is ready)
|
||||
const payButton = page.locator(".SubmitButton");
|
||||
await payButton.waitFor({ state: "attached", timeout: 30_000 });
|
||||
console.log("Stripe checkout form loaded");
|
||||
|
||||
// Fill in card number
|
||||
const cardNumberInput = page.locator("#cardNumber");
|
||||
await cardNumberInput.fill("5105105105105100");
|
||||
|
||||
// Fill in expiry date
|
||||
const cardExpiryInput = page.locator("#cardExpiry");
|
||||
await cardExpiryInput.fill("12/35");
|
||||
|
||||
// Fill in CVC
|
||||
const cardCvcInput = page.locator("#cardCvc");
|
||||
await cardCvcInput.fill("123");
|
||||
|
||||
// Fill in cardholder name
|
||||
const billingNameInput = page.locator("#billingName");
|
||||
await billingNameInput.fill("Testy Tester");
|
||||
|
||||
// Fill in ZIP code
|
||||
const postalCodeInput = page.locator("#billingPostalCode");
|
||||
await postalCodeInput.fill("12345");
|
||||
|
||||
// Take screenshot of filled Stripe form
|
||||
await page.screenshot({
|
||||
path: "test-results/screenshots/stripe-checkout-filled.png",
|
||||
});
|
||||
|
||||
// Click Pay button
|
||||
await payButton.click();
|
||||
|
||||
// Wait for redirect back to billing page
|
||||
await page.waitForURL(/\/settings\/billing/, { timeout: 60_000 });
|
||||
console.log("Returned to billing page after payment");
|
||||
|
||||
// Wait for balance to update (may need to wait for API refresh)
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify balance increased by $10
|
||||
await expect(balanceElement).toBeVisible({ timeout: 10_000 });
|
||||
const newBalanceText = await balanceElement.textContent();
|
||||
const newBalance = parseFloat(newBalanceText?.replace("$", "") || "0");
|
||||
console.log(`New balance: $${newBalance.toFixed(2)}`);
|
||||
|
||||
const expectedBalance = initialBalance + 10;
|
||||
expect(newBalance).toBeCloseTo(expectedBalance, 2);
|
||||
console.log(
|
||||
`Balance increased by $10: $${initialBalance.toFixed(2)} -> $${newBalance.toFixed(2)}`,
|
||||
);
|
||||
|
||||
// Take screenshot of updated balance
|
||||
await page.screenshot({
|
||||
path: "test-results/screenshots/billing-after-payment.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("should be able to start a conversation, send a prompt, and receive response @critical", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Navigate to home
|
||||
await homePage.goto();
|
||||
|
||||
if (TEST_REPO_URL) {
|
||||
// Select repository if repo selection is available
|
||||
try {
|
||||
await homePage.selectRepository(TEST_REPO_URL);
|
||||
console.log(`Selected repository: ${TEST_REPO_URL}`);
|
||||
} catch (e) {
|
||||
console.log(
|
||||
"Repository selection not available or failed, continuing...",
|
||||
);
|
||||
}
|
||||
// Start a new conversation
|
||||
await homePage.startNewConversation("repo-launch-button");
|
||||
} else {
|
||||
await homePage.startNewConversation("launch-new-conversation-button");
|
||||
}
|
||||
|
||||
// Wait for conversation page to load
|
||||
await page.waitForTimeout(2000); // Allow navigation to complete
|
||||
|
||||
// Initialize conversation page
|
||||
conversationPage = new ConversationPage(page);
|
||||
|
||||
// Wait for the agent to be ready
|
||||
await conversationPage.waitForConversationReady();
|
||||
|
||||
// Verify chat interface is available
|
||||
await expect(conversationPage.chatBox).toBeVisible();
|
||||
await expect(conversationPage.chatInput).toBeVisible();
|
||||
|
||||
// Take screenshot before sending message
|
||||
await page.screenshot({
|
||||
path: "test-results/screenshots/conversation-ready.png",
|
||||
});
|
||||
|
||||
// Execute the test prompt
|
||||
const prompt = "Reverse the word 'hello'";
|
||||
console.log(`Sending prompt: "${prompt}"`);
|
||||
await conversationPage.executePrompt(prompt, 120_000);
|
||||
|
||||
// Wait for a message containing the expected reversed word
|
||||
const message = await conversationPage.waitForMessageContaining(
|
||||
"olleh",
|
||||
120_000,
|
||||
);
|
||||
console.log(
|
||||
`Found expected response containing 'olleh': "${message.substring(0, 100)}..."`,
|
||||
);
|
||||
|
||||
// Take screenshot of successful response
|
||||
await page.screenshot({
|
||||
path: "test-results/screenshots/agent-response.png",
|
||||
});
|
||||
|
||||
console.log("Smoke test passed: Agent correctly reversed the word");
|
||||
});
|
||||
|
||||
test("should be able to navigate to a running conversation @critical", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Navigate to home page
|
||||
await homePage.goto();
|
||||
|
||||
// Click on the first conversation in the recent conversations list
|
||||
await homePage.clickFirstConversation();
|
||||
|
||||
// Initialize conversation page
|
||||
conversationPage = new ConversationPage(page);
|
||||
|
||||
// Wait for the conversation to be ready by checking for "Waiting for task" status
|
||||
await conversationPage.waitForConversationReady();
|
||||
|
||||
// Take screenshot of successful navigation
|
||||
await page.screenshot({
|
||||
path: "test-results/screenshots/navigated-conversation.png",
|
||||
});
|
||||
|
||||
console.log("Successfully navigated to running conversation");
|
||||
});
|
||||
|
||||
test("should be able to use Tavily search and get accurate response", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Navigate to home page
|
||||
await homePage.goto();
|
||||
|
||||
// Click on the first conversation in the recent conversations list
|
||||
await homePage.clickFirstConversation();
|
||||
|
||||
// Initialize conversation page
|
||||
conversationPage = new ConversationPage(page);
|
||||
|
||||
// Wait for the agent to be ready
|
||||
await conversationPage.waitForConversationReady();
|
||||
|
||||
// Send the Tavily search prompt
|
||||
const prompt =
|
||||
"Using Tavily search, please tell me who is the prime minister of Ireland.";
|
||||
console.log(`Sending prompt: "${prompt}"`);
|
||||
await conversationPage.executePrompt(prompt, 180_000); // Longer timeout for search
|
||||
|
||||
// Wait for a message containing the expected answer
|
||||
const message = await conversationPage.waitForMessageContaining(
|
||||
"Micheál Martin",
|
||||
180_000,
|
||||
);
|
||||
console.log(
|
||||
`Found expected response containing 'Micheál Martin': "${message.substring(0, 100)}..."`,
|
||||
);
|
||||
|
||||
// Take screenshot of successful response
|
||||
await page.screenshot({
|
||||
path: "test-results/screenshots/tavily-search-response.png",
|
||||
});
|
||||
|
||||
console.log(
|
||||
"Tavily search test passed: Agent correctly identified the Prime Minister of Ireland",
|
||||
);
|
||||
});
|
||||
|
||||
test("should be able to create API key and use it to access the API @critical", async ({
|
||||
page,
|
||||
request,
|
||||
baseURL,
|
||||
}) => {
|
||||
const API_KEY_NAME = "Integration Test Key";
|
||||
|
||||
// Navigate to home and open user menu
|
||||
await homePage.goto();
|
||||
await homePage.openUserMenu();
|
||||
|
||||
// Click on API Keys link in the user menu
|
||||
const apiKeysLink = page.getByRole("link", { name: /api keys/i });
|
||||
await apiKeysLink.click();
|
||||
|
||||
// Wait for API Keys page to load
|
||||
await page.waitForURL(/\/settings\/api-keys/, { timeout: 30_000 });
|
||||
console.log("Navigated to API Keys page");
|
||||
|
||||
// Verify "Refresh API Key" button is visible (indicates user has credits)
|
||||
const refreshApiKeyButton = page.getByRole("button", { name: /refresh/i });
|
||||
await expect(refreshApiKeyButton).toBeVisible({ timeout: 10_000 });
|
||||
console.log("Refresh API Key button is visible - user has credits");
|
||||
|
||||
// Delete any existing "Integration Test Key" if it exists
|
||||
const existingKeyRow = page.locator("tr", { hasText: API_KEY_NAME });
|
||||
if (await existingKeyRow.isVisible({ timeout: 2_000 }).catch(() => false)) {
|
||||
console.log(`Found existing "${API_KEY_NAME}", deleting it...`);
|
||||
const deleteButton = existingKeyRow.locator(
|
||||
'button[aria-label^="Delete"]',
|
||||
);
|
||||
await deleteButton.click();
|
||||
|
||||
// Confirm deletion in modal
|
||||
const deleteModal = page.getByTestId("delete-api-key-modal");
|
||||
await expect(deleteModal).toBeVisible({ timeout: 5_000 });
|
||||
// This is so ugly. :(
|
||||
const confirmDeleteButton = deleteModal
|
||||
.locator("xpath=..")
|
||||
.getByRole("button")
|
||||
.first();
|
||||
await confirmDeleteButton.click();
|
||||
|
||||
// Wait for modal to close
|
||||
await expect(deleteModal).not.toBeVisible({ timeout: 5_000 });
|
||||
console.log(`Deleted existing "${API_KEY_NAME}"`);
|
||||
|
||||
// Wait for the page to settle after deletion
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// Click "Create API Key" button
|
||||
const createApiKeyButton = page.getByRole("button", {
|
||||
name: /create api key/i,
|
||||
});
|
||||
await expect(createApiKeyButton).toBeVisible({ timeout: 10_000 });
|
||||
await createApiKeyButton.click();
|
||||
|
||||
// Wait for create modal to appear
|
||||
const createModal = page.getByTestId("create-api-key-modal");
|
||||
await expect(createModal).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Enter the key name
|
||||
const nameInput = page.getByTestId("api-key-name-input");
|
||||
await nameInput.fill(API_KEY_NAME);
|
||||
|
||||
// Click Create button
|
||||
const createButton = page.getByRole("button", { name: /^create$/i });
|
||||
await createButton.click();
|
||||
|
||||
// Wait for the new key modal to appear with the generated key
|
||||
const newKeyModal = page.getByTestId("new-api-key-modal");
|
||||
await expect(newKeyModal).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Capture the API key from the modal
|
||||
const keyDisplay = newKeyModal.locator(".font-mono");
|
||||
const apiKey = await keyDisplay.textContent();
|
||||
expect(apiKey).toBeTruthy();
|
||||
console.log(`Created API key: ${apiKey?.substring(0, 20)}...`);
|
||||
|
||||
// Close the modal
|
||||
const closeButton = page.getByRole("button", { name: /close/i });
|
||||
await closeButton.click();
|
||||
await expect(newKeyModal).not.toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Take screenshot of API keys page
|
||||
await page.screenshot({
|
||||
path: "test-results/screenshots/api-keys-created.png",
|
||||
});
|
||||
|
||||
// Test the API key by making a request to /api/v1/sandboxes/search
|
||||
console.log("Testing API key with sandboxes search endpoint...");
|
||||
const response = await request.get(`${baseURL}/api/v1/sandboxes/search`, {
|
||||
headers: {
|
||||
"X-Access-Token": apiKey!,
|
||||
},
|
||||
});
|
||||
|
||||
// Verify the response
|
||||
expect(response.ok()).toBe(true);
|
||||
const responseBody = await response.json();
|
||||
console.log(
|
||||
`Sandboxes search response: ${JSON.stringify(responseBody).substring(0, 200)}...`,
|
||||
);
|
||||
|
||||
// Verify we got at least 1 sandbox (the currently running one)
|
||||
// Response format: { items: [], next_page_id: string | null }
|
||||
expect(responseBody).toHaveProperty("items");
|
||||
expect(Array.isArray(responseBody.items)).toBe(true);
|
||||
expect(responseBody.items.length).toBeGreaterThanOrEqual(1);
|
||||
console.log(
|
||||
`Found ${responseBody.items.length} sandbox(es) - API key works!`,
|
||||
);
|
||||
|
||||
// Take screenshot after API test
|
||||
await page.screenshot({
|
||||
path: "test-results/screenshots/api-key-test-complete.png",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Health Check Tests @smoke", () => {
|
||||
test("application should be accessible", async ({ page, baseURL }) => {
|
||||
const response = await page.goto(baseURL || "/");
|
||||
|
||||
// Verify we got a successful response
|
||||
expect(response?.status()).toBeLessThan(400);
|
||||
});
|
||||
|
||||
test("application should not have console errors on load", async ({
|
||||
page,
|
||||
}) => {
|
||||
const errors: string[] = [];
|
||||
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
// Filter out known acceptable errors
|
||||
const text = msg.text();
|
||||
if (
|
||||
!text.includes("favicon") &&
|
||||
!text.includes("sourcemap") &&
|
||||
!text.includes("DevTools")
|
||||
) {
|
||||
errors.push(text);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page
|
||||
.waitForLoadState("networkidle", { timeout: 30_000 })
|
||||
.catch(() => {});
|
||||
|
||||
// Log any errors found
|
||||
if (errors.length > 0) {
|
||||
console.log("Console errors found:", errors);
|
||||
}
|
||||
|
||||
// Fail if critical errors exist
|
||||
const criticalErrors = errors.filter(
|
||||
(e) =>
|
||||
e.includes("TypeError") ||
|
||||
e.includes("ReferenceError") ||
|
||||
e.includes("SyntaxError"),
|
||||
);
|
||||
expect(criticalErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Environment Validation @smoke", () => {
|
||||
test("should be connected to correct environment", async ({
|
||||
page,
|
||||
baseURL,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Log the current environment for verification
|
||||
console.log(`Testing against: ${baseURL}`);
|
||||
|
||||
// Verify we're on the expected domain
|
||||
const url = page.url();
|
||||
expect(url).toContain(new URL(baseURL || "").hostname);
|
||||
});
|
||||
|
||||
test("should have valid SSL certificate", async ({ page, baseURL }) => {
|
||||
// This test implicitly validates SSL because ignoreHTTPSErrors is true
|
||||
// but we still want to verify the connection works
|
||||
const response = await page.goto(baseURL || "/");
|
||||
expect(response?.ok()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"noEmit": true,
|
||||
"types": ["node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@pages/*": ["pages/*"],
|
||||
"@utils/*": ["utils/*"],
|
||||
"@fixtures/*": ["fixtures/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules", "playwright-report", "test-results"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./test-helpers";
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Utility functions for integration tests
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wait for a condition to be true with polling
|
||||
*/
|
||||
export async function waitForCondition(
|
||||
condition: () => Promise<boolean>,
|
||||
options: {
|
||||
timeout?: number;
|
||||
interval?: number;
|
||||
message?: string;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const {
|
||||
timeout = 30_000,
|
||||
interval = 500,
|
||||
message = "Condition not met",
|
||||
} = options;
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
if (await condition()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
}
|
||||
|
||||
throw new Error(`${message} within ${timeout}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a function with exponential backoff
|
||||
*/
|
||||
export async function retry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: {
|
||||
maxRetries?: number;
|
||||
baseDelay?: number;
|
||||
maxDelay?: number;
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
const { maxRetries = 3, baseDelay = 1000, maxDelay = 10000 } = options;
|
||||
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
if (attempt < maxRetries - 1) {
|
||||
const delay = Math.min(baseDelay * 2 ** attempt, maxDelay);
|
||||
console.log(
|
||||
`Retry attempt ${attempt + 1}/${maxRetries} after ${delay}ms`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique test identifier
|
||||
*/
|
||||
export function generateTestId(): string {
|
||||
return `test-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log test step with timestamp
|
||||
*/
|
||||
export function logStep(step: string): void {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(`[${timestamp}] ${step}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a screenshot with a descriptive name
|
||||
*/
|
||||
export async function takeScreenshot(
|
||||
page: Page,
|
||||
name: string,
|
||||
options: { fullPage?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const timestamp = Date.now();
|
||||
const sanitizedName = name.replace(/[^a-zA-Z0-9-_]/g, "-");
|
||||
await page.screenshot({
|
||||
path: `test-results/screenshots/${sanitizedName}-${timestamp}.png`,
|
||||
fullPage: options.fullPage ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for no console errors during an action
|
||||
*/
|
||||
export async function expectNoConsoleErrors(
|
||||
page: Page,
|
||||
action: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const errors: string[] = [];
|
||||
|
||||
const handler = (msg: import("@playwright/test").ConsoleMessage) => {
|
||||
if (msg.type() === "error") {
|
||||
const text = msg.text();
|
||||
// Filter known acceptable errors
|
||||
if (!text.includes("favicon") && !text.includes("sourcemap")) {
|
||||
errors.push(text);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
page.on("console", handler);
|
||||
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
page.off("console", handler);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Console errors detected:\n${errors.join("\n")}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment helper to get environment-specific values
|
||||
*/
|
||||
export const env = {
|
||||
baseUrl: process.env.BASE_URL || "https://staging.all-hands.dev",
|
||||
testEnv: process.env.TEST_ENV || "staging",
|
||||
testRepoUrl:
|
||||
process.env.TEST_REPO_URL || "https://github.com/OpenHands/deploy",
|
||||
testPrompt: process.env.TEST_PROMPT || "Flip a coin!",
|
||||
isCI: process.env.CI === "true",
|
||||
|
||||
getFeatureBranchUrl(branchName: string): string {
|
||||
// Sanitize branch name for URL
|
||||
const sanitized = branchName.replace(/[^a-zA-Z0-9-]/g, "-").toLowerCase();
|
||||
return `https://${sanitized}.staging.all-hands.dev`;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if running in a specific environment
|
||||
*/
|
||||
export function isEnvironment(
|
||||
env: "staging" | "production" | "local",
|
||||
): boolean {
|
||||
const baseUrl = process.env.BASE_URL || "";
|
||||
|
||||
switch (env) {
|
||||
case "staging":
|
||||
return baseUrl.includes("staging.all-hands.dev");
|
||||
case "production":
|
||||
return baseUrl.includes("app.all-hands.dev");
|
||||
case "local":
|
||||
return baseUrl.includes("localhost");
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip test in specific environments
|
||||
*/
|
||||
export function skipInEnvironment(
|
||||
test: { skip: (condition: boolean, message: string) => void },
|
||||
envs: ("staging" | "production" | "local")[],
|
||||
reason: string,
|
||||
): void {
|
||||
const shouldSkip = envs.some(isEnvironment);
|
||||
test.skip(shouldSkip, `Skipped in ${envs.join(", ")}: ${reason}`);
|
||||
}
|
||||
@@ -106,15 +106,14 @@ class EventServiceBase(EventService, ABC):
|
||||
reverse=(sort_order == EventSortOrder.TIMESTAMP_DESC),
|
||||
)
|
||||
|
||||
# Apply pagination to items (not paths)
|
||||
start_offset = 0
|
||||
next_page_id = None
|
||||
if page_id:
|
||||
start_offset = int(page_id)
|
||||
items = items[start_offset:]
|
||||
if len(items) > limit:
|
||||
paths = paths[start_offset:]
|
||||
if len(paths) > limit:
|
||||
paths = paths[:limit]
|
||||
next_page_id = str(start_offset + limit)
|
||||
items = items[:limit]
|
||||
|
||||
return EventPage(items=items, next_page_id=next_page_id)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import logging
|
||||
import pkgutil
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from fastapi.security import APIKeyHeader
|
||||
from jwt import InvalidTokenError
|
||||
from pydantic import SecretStr
|
||||
@@ -23,87 +23,61 @@ from openhands.app_server.config import (
|
||||
depends_app_conversation_info_service,
|
||||
depends_event_service,
|
||||
depends_jwt_service,
|
||||
depends_sandbox_service,
|
||||
get_event_callback_service,
|
||||
get_global_config,
|
||||
get_sandbox_service,
|
||||
)
|
||||
from openhands.app_server.errors import AuthError
|
||||
from openhands.app_server.event.event_service import EventService
|
||||
from openhands.app_server.sandbox.sandbox_models import SandboxInfo
|
||||
from openhands.app_server.sandbox.sandbox_service import SandboxService
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.services.jwt_service import JwtService
|
||||
from openhands.app_server.user.auth_user_context import AuthUserContext
|
||||
from openhands.app_server.user.specifiy_user_context import (
|
||||
ADMIN,
|
||||
USER_CONTEXT_ATTR,
|
||||
SpecifyUserContext,
|
||||
as_admin,
|
||||
)
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.sdk import ConversationExecutionStatus, Event
|
||||
from openhands.sdk.event import ConversationStateUpdateEvent
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.server.user_auth.default_user_auth import DefaultUserAuth
|
||||
from openhands.server.user_auth.user_auth import (
|
||||
get_for_user as get_user_auth_for_user,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix='/webhooks', tags=['Webhooks'])
|
||||
sandbox_service_dependency = depends_sandbox_service()
|
||||
event_service_dependency = depends_event_service()
|
||||
app_conversation_info_service_dependency = depends_app_conversation_info_service()
|
||||
jwt_dependency = depends_jwt_service()
|
||||
app_mode = get_global_config().app_mode
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def valid_sandbox(
|
||||
request: Request,
|
||||
user_context: UserContext = Depends(as_admin),
|
||||
session_api_key: str = Depends(
|
||||
APIKeyHeader(name='X-Session-API-Key', auto_error=False)
|
||||
),
|
||||
sandbox_service: SandboxService = sandbox_service_dependency,
|
||||
) -> SandboxInfo:
|
||||
"""Use a session api key for validation, and get a sandbox. Subsequent actions
|
||||
are executed in the context of the owner of the sandbox"""
|
||||
if not session_api_key:
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED, detail='X-Session-API-Key header is required'
|
||||
)
|
||||
|
||||
# Create a state which will be used internally only for this operation
|
||||
state = InjectorState()
|
||||
|
||||
# Since we need access to all sandboxes, this is executed in the context of the admin.
|
||||
setattr(state, USER_CONTEXT_ATTR, ADMIN)
|
||||
async with get_sandbox_service(state) as sandbox_service:
|
||||
sandbox_info = await sandbox_service.get_sandbox_by_session_api_key(
|
||||
session_api_key
|
||||
sandbox_info = await sandbox_service.get_sandbox_by_session_api_key(session_api_key)
|
||||
if sandbox_info is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED, detail='Invalid session API key'
|
||||
)
|
||||
if sandbox_info is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED, detail='Invalid session API key'
|
||||
)
|
||||
|
||||
# In SAAS Mode there is always a user, so we set the owner of the sandbox
|
||||
# as the current user (Validated by the session_api_key they provided)
|
||||
if sandbox_info.created_by_user_id:
|
||||
setattr(
|
||||
request.state,
|
||||
USER_CONTEXT_ATTR,
|
||||
SpecifyUserContext(sandbox_info.created_by_user_id),
|
||||
)
|
||||
elif app_mode == AppMode.SAAS:
|
||||
_logger.error(
|
||||
'Sandbox had no user specified', extra={'sandbox_id': sandbox_info.id}
|
||||
)
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED, detail='Sandbox had no user specified'
|
||||
)
|
||||
|
||||
return sandbox_info
|
||||
return sandbox_info
|
||||
|
||||
|
||||
async def valid_conversation(
|
||||
conversation_id: UUID,
|
||||
sandbox_info: SandboxInfo = Depends(valid_sandbox),
|
||||
sandbox_info: SandboxInfo,
|
||||
app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency,
|
||||
) -> AppConversationInfo:
|
||||
app_conversation_info = (
|
||||
@@ -116,11 +90,9 @@ async def valid_conversation(
|
||||
sandbox_id=sandbox_info.id,
|
||||
created_by_user_id=sandbox_info.created_by_user_id,
|
||||
)
|
||||
|
||||
# Sanity check - Make sure that the conversation and sandbox were created by the same user
|
||||
if app_conversation_info.created_by_user_id != sandbox_info.created_by_user_id:
|
||||
# Make sure that the conversation and sandbox were created by the same user
|
||||
raise AuthError()
|
||||
|
||||
return app_conversation_info
|
||||
|
||||
|
||||
@@ -167,11 +139,15 @@ async def on_conversation_update(
|
||||
async def on_event(
|
||||
events: list[Event],
|
||||
conversation_id: UUID,
|
||||
app_conversation_info: AppConversationInfo = Depends(valid_conversation),
|
||||
sandbox_info: SandboxInfo = Depends(valid_sandbox),
|
||||
app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency,
|
||||
event_service: EventService = event_service_dependency,
|
||||
) -> Success:
|
||||
"""Webhook callback for when event stream events occur."""
|
||||
app_conversation_info = await valid_conversation(
|
||||
conversation_id, sandbox_info, app_conversation_info_service
|
||||
)
|
||||
|
||||
try:
|
||||
# Save events...
|
||||
await asyncio.gather(
|
||||
|
||||
@@ -13,7 +13,7 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin
|
||||
|
||||
# The version of the agent server to use for deployments.
|
||||
# Typically this will be the same as the values from the pyproject.toml
|
||||
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:1.13.0-python'
|
||||
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:1.12.0-python'
|
||||
|
||||
|
||||
class SandboxSpecService(ABC):
|
||||
|
||||
Generated
+23
-21
@@ -6367,14 +6367,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.13.0"
|
||||
version = "1.12.0"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_agent_server-1.13.0-py3-none-any.whl", hash = "sha256:88bb8bfb03ff0cc7a7d32ffabd108d0a284f4333f33a9de27ce158b6d828bc29"},
|
||||
{file = "openhands_agent_server-1.13.0.tar.gz", hash = "sha256:6f8b296c0f26a478d4eb49668a353e2b6997c39022c2bbcc36325f5f08887a7a"},
|
||||
{file = "openhands_agent_server-1.12.0-py3-none-any.whl", hash = "sha256:3bd62fef10092f1155af116a8a7417041d574eff9d4e4b6f7a24bfc432de2fad"},
|
||||
{file = "openhands_agent_server-1.12.0.tar.gz", hash = "sha256:7ea7ce579175f713ed68b68cde5d685ef694627ac7bbff40d2e22913f065c46d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6391,14 +6391,14 @@ wsproto = ">=1.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.13.0"
|
||||
version = "1.12.0"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_sdk-1.13.0-py3-none-any.whl", hash = "sha256:ec83f9fa2934aae9c4ce1c0365a7037f7e17869affa44a40e71ba49d2bef7185"},
|
||||
{file = "openhands_sdk-1.13.0.tar.gz", hash = "sha256:fbb2a2dc4852ea23cc697a36fb3f95ca47cfef432b0d195c496de6f374caad9c"},
|
||||
{file = "openhands_sdk-1.12.0-py3-none-any.whl", hash = "sha256:857793f5c27fd63c0d4d37762550e6c504a03dd06116475c23adcc14bb5c4c02"},
|
||||
{file = "openhands_sdk-1.12.0.tar.gz", hash = "sha256:ac348e7134ea21e1ab453978962504aff8eb47e62df1fb7a503d769d55658ea9"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6421,14 +6421,14 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.13.0"
|
||||
version = "1.12.0"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_tools-1.13.0-py3-none-any.whl", hash = "sha256:87073b868e20f9c769497f480e0d15b14ca41314c3d1cb5076029f37408a1d68"},
|
||||
{file = "openhands_tools-1.13.0.tar.gz", hash = "sha256:e1181701efab5bc3133566e3b1640027824147438959cd8ce7430c941896704d"},
|
||||
{file = "openhands_tools-1.12.0-py3-none-any.whl", hash = "sha256:57207e9e30f9d7fe9121cd21b072580cfdc2a00831edeaf8e8d685d721bb9e33"},
|
||||
{file = "openhands_tools-1.12.0.tar.gz", hash = "sha256:f2b4d81d0b6771f5416f8b702db09a14999fa8e553073bcf38f344e29aae770c"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -13579,22 +13579,24 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "tornado"
|
||||
version = "6.5.5"
|
||||
version = "6.5.4"
|
||||
description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "runtime"]
|
||||
files = [
|
||||
{file = "tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6"},
|
||||
{file = "tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1"},
|
||||
{file = "tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -14846,4 +14848,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "8988a1da93e30d92a44ff7690ad39ce34a164c3a7b249e0d63a270a505bd52a9"
|
||||
content-hash = "7319bfec87aed5ed2803ad7cb947f875e83fa62216b1662a87b9b84078dc03e4"
|
||||
|
||||
+6
-6
@@ -57,9 +57,9 @@ dependencies = [
|
||||
"numpy",
|
||||
"openai==2.8",
|
||||
"openhands-aci==0.3.3",
|
||||
"openhands-agent-server==1.13",
|
||||
"openhands-sdk==1.13",
|
||||
"openhands-tools==1.13",
|
||||
"openhands-agent-server==1.12",
|
||||
"openhands-sdk==1.12",
|
||||
"openhands-tools==1.12",
|
||||
"opentelemetry-api>=1.33.1",
|
||||
"opentelemetry-exporter-otlp-proto-grpc>=1.33.1",
|
||||
"pathspec>=0.12.1",
|
||||
@@ -249,9 +249,9 @@ e2b-code-interpreter = { version = "^2.0.0", optional = true }
|
||||
pybase62 = "^1.0.0"
|
||||
|
||||
# V1 dependencies
|
||||
openhands-sdk = "1.13"
|
||||
openhands-agent-server = "1.13"
|
||||
openhands-tools = "1.13"
|
||||
openhands-sdk = "1.12"
|
||||
openhands-agent-server = "1.12"
|
||||
openhands-tools = "1.12"
|
||||
jwcrypto = ">=1.5.6"
|
||||
sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" }
|
||||
pg8000 = "^1.31.5"
|
||||
|
||||
@@ -161,113 +161,6 @@ class TestFilesystemEventServiceSearchEvents:
|
||||
assert hasattr(result, 'next_page_id')
|
||||
assert len(result.items) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_pagination_limits_results(
|
||||
self, service: FilesystemEventService
|
||||
):
|
||||
"""Test that search_events respects the limit parameter for pagination."""
|
||||
conversation_id = uuid4()
|
||||
total_events = 10
|
||||
page_limit = 3
|
||||
|
||||
# Create more events than the limit
|
||||
for _ in range(total_events):
|
||||
await service.save_event(conversation_id, create_token_event())
|
||||
|
||||
# First page should return only 'limit' events
|
||||
result = await service.search_events(conversation_id, limit=page_limit)
|
||||
|
||||
assert len(result.items) == page_limit
|
||||
assert result.next_page_id is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_pagination_iterates_all_events(
|
||||
self, service: FilesystemEventService
|
||||
):
|
||||
"""Test that pagination correctly iterates through all events without duplicates.
|
||||
|
||||
This test verifies the fix for a bug where pagination was applied to 'paths'
|
||||
instead of 'items', causing all events to be returned on every page.
|
||||
"""
|
||||
conversation_id = uuid4()
|
||||
total_events = 10
|
||||
page_limit = 3
|
||||
|
||||
# Create events and track their IDs
|
||||
created_event_ids = set()
|
||||
for _ in range(total_events):
|
||||
event = create_token_event()
|
||||
created_event_ids.add(event.id)
|
||||
await service.save_event(conversation_id, event)
|
||||
|
||||
# Iterate through all pages and collect event IDs
|
||||
collected_event_ids = set()
|
||||
page_id = None
|
||||
page_count = 0
|
||||
|
||||
while True:
|
||||
result = await service.search_events(
|
||||
conversation_id, page_id=page_id, limit=page_limit
|
||||
)
|
||||
page_count += 1
|
||||
|
||||
for item in result.items:
|
||||
# Verify no duplicates - this would fail with the old buggy code
|
||||
assert item.id not in collected_event_ids, (
|
||||
f'Duplicate event {item.id} found on page {page_count}'
|
||||
)
|
||||
collected_event_ids.add(item.id)
|
||||
|
||||
if result.next_page_id is None:
|
||||
break
|
||||
page_id = result.next_page_id
|
||||
|
||||
# Verify we got all events exactly once
|
||||
assert collected_event_ids == created_event_ids
|
||||
assert len(collected_event_ids) == total_events
|
||||
|
||||
# With 10 events and limit of 3, we should have 4 pages (3+3+3+1)
|
||||
expected_pages = (total_events + page_limit - 1) // page_limit
|
||||
assert page_count == expected_pages
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_pagination_with_filters(
|
||||
self, service: FilesystemEventService
|
||||
):
|
||||
"""Test that pagination works correctly when combined with filters."""
|
||||
conversation_id = uuid4()
|
||||
|
||||
# Create a mix of events
|
||||
token_events = [create_token_event() for _ in range(5)]
|
||||
pause_events = [create_pause_event() for _ in range(3)]
|
||||
|
||||
for event in token_events + pause_events:
|
||||
await service.save_event(conversation_id, event)
|
||||
|
||||
# Search only for token events with pagination
|
||||
page_limit = 2
|
||||
collected_ids = set()
|
||||
page_id = None
|
||||
|
||||
while True:
|
||||
result = await service.search_events(
|
||||
conversation_id,
|
||||
kind__eq='TokenEvent',
|
||||
page_id=page_id,
|
||||
limit=page_limit,
|
||||
)
|
||||
|
||||
for item in result.items:
|
||||
assert item.kind == 'TokenEvent'
|
||||
collected_ids.add(item.id)
|
||||
|
||||
if result.next_page_id is None:
|
||||
break
|
||||
page_id = result.next_page_id
|
||||
|
||||
# Should have found all 5 token events
|
||||
assert len(collected_ids) == 5
|
||||
|
||||
|
||||
class TestFilesystemEventServiceIntegration:
|
||||
"""Integration tests for FilesystemEventService."""
|
||||
|
||||
@@ -3,65 +3,18 @@
|
||||
This module tests the webhook authentication and authorization logic.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException, status
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from openhands.app_server.event_callback.webhook_router import (
|
||||
router as webhook_router,
|
||||
)
|
||||
from openhands.app_server.event_callback.webhook_router import (
|
||||
valid_conversation,
|
||||
valid_sandbox,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_models import SandboxInfo, SandboxStatus
|
||||
from openhands.app_server.user.specifiy_user_context import (
|
||||
USER_CONTEXT_ATTR,
|
||||
SpecifyUserContext,
|
||||
)
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
class MockRequestState:
|
||||
"""A mock request state that tracks attribute assignments."""
|
||||
|
||||
def __init__(self):
|
||||
self._state = {}
|
||||
self._attributes = {}
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
if name.startswith('_'):
|
||||
super().__setattr__(name, value)
|
||||
else:
|
||||
self._attributes[name] = value
|
||||
|
||||
def __getattr__(self, name):
|
||||
if name in self._attributes:
|
||||
return self._attributes[name]
|
||||
raise AttributeError(
|
||||
f"'{type(self).__name__}' object has no attribute '{name}'"
|
||||
)
|
||||
|
||||
|
||||
def create_mock_request():
|
||||
"""Create a mock FastAPI Request object with proper state."""
|
||||
request = MagicMock()
|
||||
request.state = MockRequestState()
|
||||
return request
|
||||
|
||||
|
||||
def create_sandbox_service_context_manager(sandbox_service):
|
||||
"""Create an async context manager that yields the given sandbox service."""
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _context_manager(state, request=None):
|
||||
yield sandbox_service
|
||||
|
||||
return _context_manager
|
||||
from openhands.app_server.user.specifiy_user_context import ADMIN
|
||||
|
||||
|
||||
class TestValidSandbox:
|
||||
@@ -69,15 +22,14 @@ class TestValidSandbox:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_sandbox_with_valid_api_key(self):
|
||||
"""Test that valid API key returns sandbox info and sets user_context."""
|
||||
"""Test that valid API key returns sandbox info."""
|
||||
# Arrange
|
||||
session_api_key = 'valid-api-key-123'
|
||||
user_id = 'user-123'
|
||||
expected_sandbox = SandboxInfo(
|
||||
id='sandbox-123',
|
||||
status=SandboxStatus.RUNNING,
|
||||
session_api_key=session_api_key,
|
||||
created_by_user_id=user_id,
|
||||
created_by_user_id='user-123',
|
||||
sandbox_spec_id='spec-123',
|
||||
)
|
||||
|
||||
@@ -86,17 +38,12 @@ class TestValidSandbox:
|
||||
return_value=expected_sandbox
|
||||
)
|
||||
|
||||
mock_request = create_mock_request()
|
||||
|
||||
# Act
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
|
||||
create_sandbox_service_context_manager(mock_sandbox_service),
|
||||
):
|
||||
result = await valid_sandbox(
|
||||
request=mock_request,
|
||||
session_api_key=session_api_key,
|
||||
)
|
||||
result = await valid_sandbox(
|
||||
user_context=ADMIN,
|
||||
session_api_key=session_api_key,
|
||||
sandbox_service=mock_sandbox_service,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == expected_sandbox
|
||||
@@ -104,136 +51,18 @@ class TestValidSandbox:
|
||||
session_api_key
|
||||
)
|
||||
|
||||
# Verify user_context is set correctly on request.state
|
||||
assert USER_CONTEXT_ATTR in mock_request.state._attributes
|
||||
user_context = mock_request.state._attributes[USER_CONTEXT_ATTR]
|
||||
assert isinstance(user_context, SpecifyUserContext)
|
||||
assert user_context.user_id == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_sandbox_sets_user_context_to_sandbox_owner(self):
|
||||
"""Test that user_context is set to the sandbox owner's user ID."""
|
||||
# Arrange
|
||||
session_api_key = 'valid-api-key'
|
||||
sandbox_owner_id = 'sandbox-owner-user-id'
|
||||
expected_sandbox = SandboxInfo(
|
||||
id='sandbox-456',
|
||||
status=SandboxStatus.RUNNING,
|
||||
session_api_key=session_api_key,
|
||||
created_by_user_id=sandbox_owner_id,
|
||||
sandbox_spec_id='spec-456',
|
||||
)
|
||||
|
||||
mock_sandbox_service = AsyncMock()
|
||||
mock_sandbox_service.get_sandbox_by_session_api_key = AsyncMock(
|
||||
return_value=expected_sandbox
|
||||
)
|
||||
|
||||
mock_request = create_mock_request()
|
||||
|
||||
# Act
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
|
||||
create_sandbox_service_context_manager(mock_sandbox_service),
|
||||
):
|
||||
await valid_sandbox(
|
||||
request=mock_request,
|
||||
session_api_key=session_api_key,
|
||||
)
|
||||
|
||||
# Assert - user_context should be set to the sandbox owner
|
||||
assert USER_CONTEXT_ATTR in mock_request.state._attributes
|
||||
user_context = mock_request.state._attributes[USER_CONTEXT_ATTR]
|
||||
assert isinstance(user_context, SpecifyUserContext)
|
||||
assert user_context.user_id == sandbox_owner_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_sandbox_no_user_context_when_no_user_id(self):
|
||||
"""Test that user_context is not set when sandbox has no created_by_user_id."""
|
||||
# Arrange
|
||||
session_api_key = 'valid-api-key'
|
||||
expected_sandbox = SandboxInfo(
|
||||
id='sandbox-789',
|
||||
status=SandboxStatus.RUNNING,
|
||||
session_api_key=session_api_key,
|
||||
created_by_user_id=None, # No user ID
|
||||
sandbox_spec_id='spec-789',
|
||||
)
|
||||
|
||||
mock_sandbox_service = AsyncMock()
|
||||
mock_sandbox_service.get_sandbox_by_session_api_key = AsyncMock(
|
||||
return_value=expected_sandbox
|
||||
)
|
||||
|
||||
mock_request = create_mock_request()
|
||||
|
||||
# Act
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
|
||||
create_sandbox_service_context_manager(mock_sandbox_service),
|
||||
):
|
||||
result = await valid_sandbox(
|
||||
request=mock_request,
|
||||
session_api_key=session_api_key,
|
||||
)
|
||||
|
||||
# Assert - sandbox is returned but user_context should NOT be set
|
||||
assert result == expected_sandbox
|
||||
|
||||
# Verify user_context is NOT set on request.state
|
||||
assert USER_CONTEXT_ATTR not in mock_request.state._attributes
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_sandbox_no_user_context_when_no_user_id_raises_401_in_saas_mode(
|
||||
self,
|
||||
):
|
||||
"""Test that user_context is not set when sandbox has no created_by_user_id."""
|
||||
# Arrange
|
||||
session_api_key = 'valid-api-key'
|
||||
expected_sandbox = SandboxInfo(
|
||||
id='sandbox-789',
|
||||
status=SandboxStatus.RUNNING,
|
||||
session_api_key=session_api_key,
|
||||
created_by_user_id=None, # No user ID
|
||||
sandbox_spec_id='spec-789',
|
||||
)
|
||||
|
||||
mock_sandbox_service = AsyncMock()
|
||||
mock_sandbox_service.get_sandbox_by_session_api_key = AsyncMock(
|
||||
return_value=expected_sandbox
|
||||
)
|
||||
|
||||
mock_request = create_mock_request()
|
||||
|
||||
# Act
|
||||
with (
|
||||
patch(
|
||||
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
|
||||
create_sandbox_service_context_manager(mock_sandbox_service),
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.event_callback.webhook_router.app_mode',
|
||||
AppMode.SAAS,
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
await valid_sandbox(
|
||||
request=mock_request,
|
||||
session_api_key=session_api_key,
|
||||
)
|
||||
assert excinfo.value.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_sandbox_without_api_key_raises_401(self):
|
||||
"""Test that missing API key raises 401 error."""
|
||||
# Arrange
|
||||
mock_request = create_mock_request()
|
||||
mock_sandbox_service = AsyncMock()
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await valid_sandbox(
|
||||
request=mock_request,
|
||||
user_context=ADMIN,
|
||||
session_api_key=None,
|
||||
sandbox_service=mock_sandbox_service,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
@@ -249,18 +78,13 @@ class TestValidSandbox:
|
||||
return_value=None
|
||||
)
|
||||
|
||||
mock_request = create_mock_request()
|
||||
|
||||
# Act & Assert
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
|
||||
create_sandbox_service_context_manager(mock_sandbox_service),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await valid_sandbox(
|
||||
request=mock_request,
|
||||
session_api_key=session_api_key,
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await valid_sandbox(
|
||||
user_context=ADMIN,
|
||||
session_api_key=session_api_key,
|
||||
sandbox_service=mock_sandbox_service,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert 'Invalid session API key' in exc_info.value.detail
|
||||
@@ -271,13 +95,13 @@ class TestValidSandbox:
|
||||
# Arrange - empty string is falsy, so it gets rejected at the check
|
||||
session_api_key = ''
|
||||
mock_sandbox_service = AsyncMock()
|
||||
mock_request = create_mock_request()
|
||||
|
||||
# Act & Assert - should raise 401 because empty string fails the truth check
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await valid_sandbox(
|
||||
request=mock_request,
|
||||
user_context=ADMIN,
|
||||
session_api_key=session_api_key,
|
||||
sandbox_service=mock_sandbox_service,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
@@ -439,17 +263,12 @@ class TestWebhookAuthenticationIntegration:
|
||||
return_value=conversation_info
|
||||
)
|
||||
|
||||
mock_request = create_mock_request()
|
||||
|
||||
# Act - Call valid_sandbox first
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
|
||||
create_sandbox_service_context_manager(mock_sandbox_service),
|
||||
):
|
||||
sandbox_result = await valid_sandbox(
|
||||
request=mock_request,
|
||||
session_api_key=session_api_key,
|
||||
)
|
||||
sandbox_result = await valid_sandbox(
|
||||
user_context=ADMIN,
|
||||
session_api_key=session_api_key,
|
||||
sandbox_service=mock_sandbox_service,
|
||||
)
|
||||
|
||||
# Then call valid_conversation
|
||||
conversation_result = await valid_conversation(
|
||||
@@ -472,18 +291,13 @@ class TestWebhookAuthenticationIntegration:
|
||||
return_value=None
|
||||
)
|
||||
|
||||
mock_request = create_mock_request()
|
||||
|
||||
# Act & Assert - Should fail at valid_sandbox
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
|
||||
create_sandbox_service_context_manager(mock_sandbox_service),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await valid_sandbox(
|
||||
request=mock_request,
|
||||
session_api_key=session_api_key,
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await valid_sandbox(
|
||||
user_context=ADMIN,
|
||||
session_api_key=session_api_key,
|
||||
sandbox_service=mock_sandbox_service,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
@@ -514,17 +328,12 @@ class TestWebhookAuthenticationIntegration:
|
||||
return_value=different_user_info
|
||||
)
|
||||
|
||||
mock_request = create_mock_request()
|
||||
|
||||
# Act - valid_sandbox succeeds
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
|
||||
create_sandbox_service_context_manager(mock_sandbox_service),
|
||||
):
|
||||
sandbox_result = await valid_sandbox(
|
||||
request=mock_request,
|
||||
session_api_key=session_api_key,
|
||||
)
|
||||
sandbox_result = await valid_sandbox(
|
||||
user_context=ADMIN,
|
||||
session_api_key=session_api_key,
|
||||
sandbox_service=mock_sandbox_service,
|
||||
)
|
||||
|
||||
# But valid_conversation fails
|
||||
from openhands.app_server.errors import AuthError
|
||||
@@ -535,88 +344,3 @@ class TestWebhookAuthenticationIntegration:
|
||||
sandbox_info=sandbox_result,
|
||||
app_conversation_info_service=mock_conversation_service,
|
||||
)
|
||||
|
||||
|
||||
class TestWebhookRouterHTTPIntegration:
|
||||
"""Integration tests for webhook router HTTP layer.
|
||||
|
||||
These tests validate that FastAPI routing correctly extracts conversation_id
|
||||
from the request body rather than requiring it as a query parameter.
|
||||
"""
|
||||
|
||||
def test_conversation_update_endpoint_does_not_require_query_param(self):
|
||||
"""Test that /webhooks/conversations endpoint accepts conversation_id in body only.
|
||||
|
||||
This test validates the fix for the regression where the endpoint incorrectly
|
||||
required conversation_id as a query parameter due to using Depends(valid_conversation).
|
||||
|
||||
The endpoint should:
|
||||
1. Accept POST requests without any query parameters
|
||||
2. Extract conversation_id from the request body (conversation_info.id)
|
||||
3. Return 401 (not 422) when auth fails, proving the request was parsed correctly
|
||||
"""
|
||||
# Create a minimal FastAPI app with just the webhook router
|
||||
app = FastAPI()
|
||||
app.include_router(webhook_router, prefix='/api/v1')
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
# Create a valid request body with conversation_id in it
|
||||
conversation_id = str(uuid4())
|
||||
request_body = {
|
||||
'id': conversation_id,
|
||||
'execution_status': 'running',
|
||||
'agent': {
|
||||
'llm': {
|
||||
'model': 'gpt-4',
|
||||
},
|
||||
},
|
||||
'stats': {
|
||||
'usage_to_metrics': {},
|
||||
},
|
||||
}
|
||||
|
||||
# POST to /webhooks/conversations WITHOUT any query parameters
|
||||
# If the old bug existed (conversation_id required as query param),
|
||||
# FastAPI would return 422 Unprocessable Entity
|
||||
response = client.post(
|
||||
'/api/v1/webhooks/conversations',
|
||||
json=request_body,
|
||||
# No X-Session-API-Key header - should fail auth but NOT validation
|
||||
)
|
||||
|
||||
# We expect 401 Unauthorized (missing session API key)
|
||||
# NOT 422 Unprocessable Entity (which would indicate conversation_id
|
||||
# was incorrectly required as a query parameter)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED, (
|
||||
f'Expected 401 (auth failure), got {response.status_code}. '
|
||||
f'If 422, the endpoint incorrectly requires conversation_id as query param. '
|
||||
f'Response: {response.json()}'
|
||||
)
|
||||
assert response.json()['detail'] == 'X-Session-API-Key header is required'
|
||||
|
||||
def test_events_endpoint_still_requires_conversation_id_in_path(self):
|
||||
"""Test that /webhooks/events/{conversation_id} correctly requires path param.
|
||||
|
||||
This ensures we didn't accidentally break the events endpoint which legitimately
|
||||
requires conversation_id as a path parameter.
|
||||
"""
|
||||
# Create a minimal FastAPI app with just the webhook router
|
||||
app = FastAPI()
|
||||
app.include_router(webhook_router, prefix='/api/v1')
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
conversation_id = str(uuid4())
|
||||
request_body = [] # Empty events list
|
||||
|
||||
# POST to /webhooks/events/{conversation_id} with path parameter
|
||||
response = client.post(
|
||||
f'/api/v1/webhooks/events/{conversation_id}',
|
||||
json=request_body,
|
||||
# No X-Session-API-Key header - should fail auth but NOT validation
|
||||
)
|
||||
|
||||
# We expect 401 Unauthorized (missing session API key)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert response.json()['detail'] == 'X-Session-API-Key header is required'
|
||||
|
||||
@@ -19,7 +19,6 @@ from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
|
||||
SQLAppConversationInfoService,
|
||||
)
|
||||
from openhands.app_server.event_callback.webhook_router import on_conversation_update
|
||||
from openhands.app_server.sandbox.sandbox_models import SandboxInfo, SandboxStatus
|
||||
from openhands.app_server.user.specifiy_user_context import SpecifyUserContext
|
||||
from openhands.app_server.utils.sql_utils import Base
|
||||
@@ -119,6 +118,9 @@ class TestOnConversationUpdateParentConversationId:
|
||||
Assert:
|
||||
- Saved conversation retains the parent_conversation_id
|
||||
"""
|
||||
from openhands.app_server.event_callback.webhook_router import (
|
||||
on_conversation_update,
|
||||
)
|
||||
|
||||
# Arrange
|
||||
parent_id = uuid4()
|
||||
@@ -135,11 +137,12 @@ class TestOnConversationUpdateParentConversationId:
|
||||
parent_conversation_id=parent_id,
|
||||
)
|
||||
|
||||
# Act - call on_conversation_update directly with mocked valid_conversation
|
||||
# Mock valid_conversation to return existing conversation
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_conversation',
|
||||
return_value=existing_conv,
|
||||
):
|
||||
# Act
|
||||
result = await on_conversation_update(
|
||||
conversation_info=mock_conversation_info,
|
||||
sandbox_info=sandbox_info,
|
||||
@@ -172,6 +175,9 @@ class TestOnConversationUpdateParentConversationId:
|
||||
Assert:
|
||||
- Saved conversation has parent_conversation_id as None
|
||||
"""
|
||||
from openhands.app_server.event_callback.webhook_router import (
|
||||
on_conversation_update,
|
||||
)
|
||||
|
||||
# Arrange
|
||||
conversation_id = mock_conversation_info.id
|
||||
@@ -185,11 +191,12 @@ class TestOnConversationUpdateParentConversationId:
|
||||
parent_conversation_id=None,
|
||||
)
|
||||
|
||||
# Act - call on_conversation_update directly with mocked valid_conversation
|
||||
# Mock valid_conversation to return existing conversation
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_conversation',
|
||||
return_value=existing_conv,
|
||||
):
|
||||
# Act
|
||||
result = await on_conversation_update(
|
||||
conversation_info=mock_conversation_info,
|
||||
sandbox_info=sandbox_info,
|
||||
@@ -221,6 +228,9 @@ class TestOnConversationUpdateParentConversationId:
|
||||
Assert:
|
||||
- New conversation has parent_conversation_id as None
|
||||
"""
|
||||
from openhands.app_server.event_callback.webhook_router import (
|
||||
on_conversation_update,
|
||||
)
|
||||
|
||||
# Arrange
|
||||
conversation_id = mock_conversation_info.id
|
||||
@@ -232,11 +242,12 @@ class TestOnConversationUpdateParentConversationId:
|
||||
created_by_user_id=sandbox_info.created_by_user_id,
|
||||
)
|
||||
|
||||
# Act - call on_conversation_update directly with mocked valid_conversation
|
||||
# Mock valid_conversation to return stub (as it would for new conversation)
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_conversation',
|
||||
return_value=stub_conv,
|
||||
):
|
||||
# Act
|
||||
result = await on_conversation_update(
|
||||
conversation_info=mock_conversation_info,
|
||||
sandbox_info=sandbox_info,
|
||||
@@ -269,6 +280,9 @@ class TestOnConversationUpdateParentConversationId:
|
||||
Assert:
|
||||
- All metadata including parent_conversation_id is preserved
|
||||
"""
|
||||
from openhands.app_server.event_callback.webhook_router import (
|
||||
on_conversation_update,
|
||||
)
|
||||
|
||||
# Arrange
|
||||
parent_id = uuid4()
|
||||
@@ -288,11 +302,12 @@ class TestOnConversationUpdateParentConversationId:
|
||||
parent_conversation_id=parent_id,
|
||||
)
|
||||
|
||||
# Act - call on_conversation_update directly with mocked valid_conversation
|
||||
# Mock valid_conversation to return existing conversation
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_conversation',
|
||||
return_value=existing_conv,
|
||||
):
|
||||
# Act
|
||||
result = await on_conversation_update(
|
||||
conversation_info=mock_conversation_info,
|
||||
sandbox_info=sandbox_info,
|
||||
@@ -334,6 +349,9 @@ class TestOnConversationUpdateParentConversationId:
|
||||
Assert:
|
||||
- Parent_conversation_id remains unchanged after all updates
|
||||
"""
|
||||
from openhands.app_server.event_callback.webhook_router import (
|
||||
on_conversation_update,
|
||||
)
|
||||
|
||||
# Arrange
|
||||
parent_id = uuid4()
|
||||
@@ -348,8 +366,9 @@ class TestOnConversationUpdateParentConversationId:
|
||||
parent_conversation_id=parent_id,
|
||||
)
|
||||
|
||||
# Act - Update multiple times, simulating what valid_conversation would return
|
||||
for _ in range(3):
|
||||
# Mock valid_conversation to return conversation with parent
|
||||
# In real scenario, this would be retrieved from DB after first save
|
||||
async def mock_valid_conv(*args, **kwargs):
|
||||
# After first save, get from DB with parent preserved
|
||||
saved = await app_conversation_info_service.get_app_conversation_info(
|
||||
conversation_id
|
||||
@@ -357,20 +376,21 @@ class TestOnConversationUpdateParentConversationId:
|
||||
if saved:
|
||||
# Override created_by_user_id for auth check
|
||||
saved.created_by_user_id = 'user_123'
|
||||
existing = saved
|
||||
else:
|
||||
existing = initial_conv
|
||||
return saved
|
||||
return initial_conv
|
||||
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_conversation',
|
||||
return_value=existing,
|
||||
):
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_conversation',
|
||||
side_effect=mock_valid_conv,
|
||||
):
|
||||
# Act - Update multiple times
|
||||
for _ in range(3):
|
||||
result = await on_conversation_update(
|
||||
conversation_info=mock_conversation_info,
|
||||
sandbox_info=sandbox_info,
|
||||
app_conversation_info_service=app_conversation_info_service,
|
||||
)
|
||||
assert isinstance(result, Success)
|
||||
assert isinstance(result, Success)
|
||||
|
||||
# Assert
|
||||
saved_conv = await app_conversation_info_service.get_app_conversation_info(
|
||||
@@ -397,6 +417,9 @@ class TestOnConversationUpdateParentConversationId:
|
||||
Assert:
|
||||
- Function returns early, no updates are made
|
||||
"""
|
||||
from openhands.app_server.event_callback.webhook_router import (
|
||||
on_conversation_update,
|
||||
)
|
||||
|
||||
# Arrange
|
||||
parent_id = uuid4()
|
||||
@@ -418,11 +441,12 @@ class TestOnConversationUpdateParentConversationId:
|
||||
# Set conversation to DELETING status
|
||||
mock_conversation_info.execution_status = ConversationExecutionStatus.DELETING
|
||||
|
||||
# Act - call on_conversation_update directly with mocked valid_conversation
|
||||
# Mock valid_conversation (though it won't be called for DELETING status)
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_conversation',
|
||||
return_value=existing_conv,
|
||||
):
|
||||
# Act
|
||||
result = await on_conversation_update(
|
||||
conversation_info=mock_conversation_info,
|
||||
sandbox_info=sandbox_info,
|
||||
@@ -457,6 +481,9 @@ class TestOnConversationUpdateParentConversationId:
|
||||
Assert:
|
||||
- Parent_conversation_id is preserved and title is generated
|
||||
"""
|
||||
from openhands.app_server.event_callback.webhook_router import (
|
||||
on_conversation_update,
|
||||
)
|
||||
|
||||
# Arrange
|
||||
parent_id = uuid4()
|
||||
@@ -471,11 +498,12 @@ class TestOnConversationUpdateParentConversationId:
|
||||
parent_conversation_id=parent_id,
|
||||
)
|
||||
|
||||
# Act - call on_conversation_update directly with mocked valid_conversation
|
||||
# Mock valid_conversation to return existing conversation
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_conversation',
|
||||
return_value=existing_conv,
|
||||
):
|
||||
# Act
|
||||
result = await on_conversation_update(
|
||||
conversation_info=mock_conversation_info,
|
||||
sandbox_info=sandbox_info,
|
||||
|
||||
@@ -451,9 +451,11 @@ class TestOnEventStatsProcessing:
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_event_processes_stats_events(self):
|
||||
"""Test that on_event processes stats events."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from openhands.app_server.event_callback.webhook_router import on_event
|
||||
from openhands.app_server.sandbox.sandbox_models import (
|
||||
SandboxInfo,
|
||||
SandboxStatus,
|
||||
)
|
||||
|
||||
conversation_id = uuid4()
|
||||
sandbox_id = 'sandbox_123'
|
||||
@@ -480,6 +482,15 @@ class TestOnEventStatsProcessing:
|
||||
|
||||
events = [stats_event, other_event]
|
||||
|
||||
# Mock dependencies
|
||||
mock_sandbox = SandboxInfo(
|
||||
id=sandbox_id,
|
||||
status=SandboxStatus.RUNNING,
|
||||
session_api_key='test_key',
|
||||
created_by_user_id='user_123',
|
||||
sandbox_spec_id='spec_123',
|
||||
)
|
||||
|
||||
mock_app_conversation_info = AppConversationInfo(
|
||||
id=conversation_id,
|
||||
sandbox_id=sandbox_id,
|
||||
@@ -488,6 +499,9 @@ class TestOnEventStatsProcessing:
|
||||
|
||||
mock_event_service = AsyncMock()
|
||||
mock_app_conversation_info_service = AsyncMock()
|
||||
mock_app_conversation_info_service.get_app_conversation_info.return_value = (
|
||||
mock_app_conversation_info
|
||||
)
|
||||
|
||||
# Set up process_stats_event to call update_conversation_statistics
|
||||
async def process_stats_event_side_effect(event, conversation_id):
|
||||
@@ -505,33 +519,44 @@ class TestOnEventStatsProcessing:
|
||||
process_stats_event_side_effect
|
||||
)
|
||||
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router._run_callbacks_in_bg_and_close'
|
||||
) as mock_callbacks:
|
||||
# Call on_event directly with dependencies
|
||||
with (
|
||||
patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_sandbox',
|
||||
return_value=mock_sandbox,
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_conversation',
|
||||
return_value=mock_app_conversation_info,
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.event_callback.webhook_router._run_callbacks_in_bg_and_close'
|
||||
) as mock_callbacks,
|
||||
):
|
||||
await on_event(
|
||||
events=events,
|
||||
conversation_id=conversation_id,
|
||||
app_conversation_info=mock_app_conversation_info,
|
||||
sandbox_info=mock_sandbox,
|
||||
app_conversation_info_service=mock_app_conversation_info_service,
|
||||
event_service=mock_event_service,
|
||||
)
|
||||
|
||||
# Verify events were saved
|
||||
assert mock_event_service.save_event.call_count == 2
|
||||
# Verify events were saved
|
||||
assert mock_event_service.save_event.call_count == 2
|
||||
|
||||
# Verify stats event was processed
|
||||
mock_app_conversation_info_service.update_conversation_statistics.assert_called_once()
|
||||
# Verify stats event was processed
|
||||
mock_app_conversation_info_service.update_conversation_statistics.assert_called_once()
|
||||
|
||||
# Verify callbacks were scheduled
|
||||
mock_callbacks.assert_called_once()
|
||||
# Verify callbacks were scheduled
|
||||
mock_callbacks.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_event_skips_non_stats_events(self):
|
||||
"""Test that on_event skips non-stats events."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from openhands.app_server.event_callback.webhook_router import on_event
|
||||
from openhands.app_server.sandbox.sandbox_models import (
|
||||
SandboxInfo,
|
||||
SandboxStatus,
|
||||
)
|
||||
from openhands.events.action.message import MessageAction
|
||||
|
||||
conversation_id = uuid4()
|
||||
@@ -543,6 +568,14 @@ class TestOnEventStatsProcessing:
|
||||
MessageAction(content='test'),
|
||||
]
|
||||
|
||||
mock_sandbox = SandboxInfo(
|
||||
id=sandbox_id,
|
||||
status=SandboxStatus.RUNNING,
|
||||
session_api_key='test_key',
|
||||
created_by_user_id='user_123',
|
||||
sandbox_spec_id='spec_123',
|
||||
)
|
||||
|
||||
mock_app_conversation_info = AppConversationInfo(
|
||||
id=conversation_id,
|
||||
sandbox_id=sandbox_id,
|
||||
@@ -551,18 +584,30 @@ class TestOnEventStatsProcessing:
|
||||
|
||||
mock_event_service = AsyncMock()
|
||||
mock_app_conversation_info_service = AsyncMock()
|
||||
mock_app_conversation_info_service.get_app_conversation_info.return_value = (
|
||||
mock_app_conversation_info
|
||||
)
|
||||
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router._run_callbacks_in_bg_and_close'
|
||||
with (
|
||||
patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_sandbox',
|
||||
return_value=mock_sandbox,
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_conversation',
|
||||
return_value=mock_app_conversation_info,
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.event_callback.webhook_router._run_callbacks_in_bg_and_close'
|
||||
),
|
||||
):
|
||||
# Call on_event directly with dependencies
|
||||
await on_event(
|
||||
events=events,
|
||||
conversation_id=conversation_id,
|
||||
app_conversation_info=mock_app_conversation_info,
|
||||
sandbox_info=mock_sandbox,
|
||||
app_conversation_info_service=mock_app_conversation_info_service,
|
||||
event_service=mock_event_service,
|
||||
)
|
||||
|
||||
# Verify stats update was NOT called
|
||||
mock_app_conversation_info_service.update_conversation_statistics.assert_not_called()
|
||||
# Verify stats update was NOT called
|
||||
mock_app_conversation_info_service.update_conversation_statistics.assert_not_called()
|
||||
|
||||
@@ -3642,7 +3642,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.13.0"
|
||||
version = "1.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
@@ -3656,9 +3656,9 @@ dependencies = [
|
||||
{ name = "websockets" },
|
||||
{ name = "wsproto" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/d0/419756ad3368e7ab47c07111dfb4bf40073c110817914e09553b8e056fe8/openhands_agent_server-1.13.0.tar.gz", hash = "sha256:6f8b296c0f26a478d4eb49668a353e2b6997c39022c2bbcc36325f5f08887a7a", size = 73594, upload-time = "2026-03-10T18:41:25.52Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/18/d76d977201ec93faf22d6cc979b5c9953a0b554bf3294cdb3186d48a5d5a/openhands_agent_server-1.12.0.tar.gz", hash = "sha256:7ea7ce579175f713ed68b68cde5d685ef694627ac7bbff40d2e22913f065c46d", size = 72715, upload-time = "2026-03-05T19:22:23.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/e1/77b9b3181e6cba89c601533757d148f911416ff968a4ea5fe0882d479ccf/openhands_agent_server-1.13.0-py3-none-any.whl", hash = "sha256:88bb8bfb03ff0cc7a7d32ffabd108d0a284f4333f33a9de27ce158b6d828bc29", size = 88607, upload-time = "2026-03-10T18:41:18.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/47/dc31d7ffd6f6687ce4cc0114e01cf1f7f13f9ba841cd47dac5a983e57fb9/openhands_agent_server-1.12.0-py3-none-any.whl", hash = "sha256:3bd62fef10092f1155af116a8a7417041d574eff9d4e4b6f7a24bfc432de2fad", size = 87800, upload-time = "2026-03-05T19:22:27.857Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3826,9 +3826,9 @@ requires-dist = [
|
||||
{ name = "numpy" },
|
||||
{ name = "openai", specifier = "==2.8" },
|
||||
{ name = "openhands-aci", specifier = "==0.3.3" },
|
||||
{ name = "openhands-agent-server", specifier = "==1.13" },
|
||||
{ name = "openhands-sdk", specifier = "==1.13" },
|
||||
{ name = "openhands-tools", specifier = "==1.13" },
|
||||
{ name = "openhands-agent-server", specifier = "==1.12" },
|
||||
{ name = "openhands-sdk", specifier = "==1.12" },
|
||||
{ name = "openhands-tools", specifier = "==1.12" },
|
||||
{ name = "opentelemetry-api", specifier = ">=1.33.1" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.33.1" },
|
||||
{ name = "pathspec", specifier = ">=0.12.1" },
|
||||
@@ -3906,7 +3906,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.13.0"
|
||||
version = "1.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "agent-client-protocol" },
|
||||
@@ -3923,14 +3923,14 @@ dependencies = [
|
||||
{ name = "tenacity" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/d0/5e35e99252f16c3e9b8eec843b7054ed7d3ad9fadcc0b40064ab3de55469/openhands_sdk-1.13.0.tar.gz", hash = "sha256:fbb2a2dc4852ea23cc697a36fb3f95ca47cfef432b0d195c496de6f374caad9c", size = 330526, upload-time = "2026-03-10T18:41:19.513Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/46/44/715dd4c43e1a4ba2c47ebd251240dd6aca0dd604cc1354932f0344f93b40/openhands_sdk-1.12.0.tar.gz", hash = "sha256:ac348e7134ea21e1ab453978962504aff8eb47e62df1fb7a503d769d55658ea9", size = 323133, upload-time = "2026-03-05T19:22:26.623Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b1/31737964179a8e5a0ed1d0485082a703e2d4cd346701ab4a383ddf33eebb/openhands_sdk-1.13.0-py3-none-any.whl", hash = "sha256:ec83f9fa2934aae9c4ce1c0365a7037f7e17869affa44a40e71ba49d2bef7185", size = 420504, upload-time = "2026-03-10T18:41:24.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/2f/b7ba4f261d806aaab46f372d2049503ccedde373bb0648b88ebce58ebfe7/openhands_sdk-1.12.0-py3-none-any.whl", hash = "sha256:857793f5c27fd63c0d4d37762550e6c504a03dd06116475c23adcc14bb5c4c02", size = 411337, upload-time = "2026-03-05T19:22:29.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.13.0"
|
||||
version = "1.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bashlex" },
|
||||
@@ -3943,9 +3943,9 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "tom-swe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/91/0af0f29dc0da57e7df13bd1653eff80d5c47b8311c6825568837d6ba2af7/openhands_tools-1.13.0.tar.gz", hash = "sha256:e1181701efab5bc3133566e3b1640027824147438959cd8ce7430c941896704d", size = 111922, upload-time = "2026-03-10T18:41:26.872Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2b/84/9552e75326c341707d36f7a86ba9a55a8fcb48bfd97e4d1ebe989260fdd8/openhands_tools-1.12.0.tar.gz", hash = "sha256:f2b4d81d0b6771f5416f8b702db09a14999fa8e553073bcf38f344e29aae770c", size = 110293, upload-time = "2026-03-05T19:22:23.906Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/e7/44d677fdd73f249c9bc8a76d2a32848ed96f54324b7d4b0589bb70f7d4e8/openhands_tools-1.13.0-py3-none-any.whl", hash = "sha256:87073b868e20f9c769497f480e0d15b14ca41314c3d1cb5076029f37408a1d68", size = 152193, upload-time = "2026-03-10T18:41:20.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/26/70031063c81bb1215f5a5d85c33c4e62e6a3d318dd8e3609e5ce68040faa/openhands_tools-1.12.0-py3-none-any.whl", hash = "sha256:57207e9e30f9d7fe9121cd21b072580cfdc2a00831edeaf8e8d685d721bb9e33", size = 150468, upload-time = "2026-03-05T19:22:24.974Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8528,19 +8528,21 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "tornado"
|
||||
version = "6.5.5"
|
||||
version = "6.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user