mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-16 18:56:02 +00:00
Compare commits
2 Commits
test/front
...
codex/spac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77a6c24e16 | ||
|
|
9a04bfc364 |
46
.github/workflows/frontend-tests.yml
vendored
46
.github/workflows/frontend-tests.yml
vendored
@@ -1,46 +0,0 @@
|
|||||||
name: Frontend Tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, synchronize, reopened, ready_for_review]
|
|
||||||
paths:
|
|
||||||
- 'web/**'
|
|
||||||
- '.github/workflows/frontend-tests.yml'
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
- develop
|
|
||||||
paths:
|
|
||||||
- 'web/**'
|
|
||||||
- '.github/workflows/frontend-tests.yml'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
playwright-smoke:
|
|
||||||
name: Playwright Smoke
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '25'
|
|
||||||
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 8.9.2
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
working-directory: web
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
|
||||||
working-directory: web
|
|
||||||
run: pnpm exec playwright install --with-deps chromium
|
|
||||||
|
|
||||||
- name: Run Playwright smoke tests
|
|
||||||
working-directory: web
|
|
||||||
run: pnpm test:e2e
|
|
||||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
|||||||
run: uv sync --dev
|
run: uv sync --dev
|
||||||
|
|
||||||
- name: Run ruff check
|
- name: Run ruff check
|
||||||
run: uv run ruff check src/langbot/ tests/ --output-format=concise
|
run: uv run ruff check src
|
||||||
|
|
||||||
- name: Run ruff format
|
- name: Run ruff format
|
||||||
run: uv run ruff format src --check
|
run: uv run ruff format src --check
|
||||||
|
|||||||
63
.github/workflows/run-tests.yml
vendored
63
.github/workflows/run-tests.yml
vendored
@@ -84,67 +84,6 @@ jobs:
|
|||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
e2e:
|
|
||||||
name: E2E Startup Tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.12'
|
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v4
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: uv sync --dev
|
|
||||||
|
|
||||||
- name: Run E2E startup tests
|
|
||||||
run: uv run pytest tests/e2e -q --tb=short
|
|
||||||
|
|
||||||
- name: E2E Test Summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
echo "## E2E Startup Test Results" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|
||||||
box-integration:
|
|
||||||
name: Box Integration Tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.12'
|
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v4
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: uv sync --dev
|
|
||||||
|
|
||||||
- name: Check Docker runtime
|
|
||||||
run: docker info
|
|
||||||
|
|
||||||
- name: Run Box integration tests
|
|
||||||
run: uv run pytest tests/integration_tests -q --tb=short
|
|
||||||
|
|
||||||
- name: Box Integration Test Summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
echo "## Box Integration Test Results" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|
||||||
coverage:
|
coverage:
|
||||||
name: Coverage Gate
|
name: Coverage Gate
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -190,4 +129,4 @@ jobs:
|
|||||||
echo "## Coverage Results" >> $GITHUB_STEP_SUMMARY
|
echo "## Coverage Results" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Threshold: 18%" >> $GITHUB_STEP_SUMMARY
|
echo "Threshold: 18%" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
echo "Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
@@ -84,8 +85,17 @@ class ModelManager:
|
|||||||
self.ap.logger.info('LangBot Space Models service is disabled, skipping sync.')
|
self.ap.logger.info('LangBot Space Models service is disabled, skipping sync.')
|
||||||
return
|
return
|
||||||
|
|
||||||
|
sync_timeout = space_config.get('models_sync_timeout')
|
||||||
try:
|
try:
|
||||||
await self.sync_new_models_from_space()
|
if sync_timeout:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
self.sync_new_models_from_space(),
|
||||||
|
timeout=float(sync_timeout),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.sync_new_models_from_space()
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
self.ap.logger.warning(f'LangBot Space model sync timed out after {sync_timeout}s, skipping startup sync.')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.ap.logger.warning('Failed to sync new models from LangBot Space, model list may not be updated.')
|
self.ap.logger.warning('Failed to sync new models from LangBot Space, model list may not be updated.')
|
||||||
self.ap.logger.warning(f' - Error: {e}')
|
self.ap.logger.warning(f' - Error: {e}')
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# LangBot Test Suite
|
# LangBot Test Suite
|
||||||
|
|
||||||
This directory contains the LangBot backend test suite, including unit tests,
|
This directory contains the test suite for LangBot, with a focus on comprehensive unit testing of pipeline stages.
|
||||||
integration tests, startup E2E tests, and container-backed Box runtime tests.
|
|
||||||
|
|
||||||
## Quality Gate Layers
|
## Quality Gate Layers
|
||||||
|
|
||||||
@@ -11,15 +10,10 @@ LangBot uses a layered quality gate system for developers and CI:
|
|||||||
|-------|---------|--------------|-------------|
|
|-------|---------|--------------|-------------|
|
||||||
| **Quick** | `make test-quick` or `bash scripts/test-quick.sh` | Ruff lint + Unit tests + Smoke tests | Before every commit |
|
| **Quick** | `make test-quick` or `bash scripts/test-quick.sh` | Ruff lint + Unit tests + Smoke tests | Before every commit |
|
||||||
| **Fast Integration** | `make test-integration-fast` or `bash scripts/test-integration-fast.sh` | SQLite/API/Pipeline integration (no external services) | Before PR, weekly |
|
| **Fast Integration** | `make test-integration-fast` or `bash scripts/test-integration-fast.sh` | SQLite/API/Pipeline integration (no external services) | Before PR, weekly |
|
||||||
| **Backend E2E** | `uv run --python 3.12 pytest tests/e2e -q --tb=short` | Starts a real LangBot process with minimal config | Before release, CI |
|
|
||||||
| **Box Integration** | `uv run --python 3.12 pytest tests/integration_tests -q --tb=short` | Real Box sandbox/runtime integration | Before Box/runtime changes, CI |
|
|
||||||
| **Frontend E2E** | `cd web && pnpm test:e2e` | Playwright smoke tests with mocked backend and Space APIs | Before web changes, CI |
|
|
||||||
| **Coverage Gate** | `make test-coverage` or `bash scripts/test-coverage.sh` | All tests with coverage, threshold: 18% | Before merge, CI |
|
| **Coverage Gate** | `make test-coverage` or `bash scripts/test-coverage.sh` | All tests with coverage, threshold: 18% | Before merge, CI |
|
||||||
| **Full Local** | `make test-all-local` | Quick + Integration + Coverage | Before major changes |
|
| **Full Local** | `make test-all-local` | Quick + Integration + Coverage | Before major changes |
|
||||||
|
|
||||||
**Note**: PostgreSQL migration tests and slow tests are NOT in local default
|
**Note**: PostgreSQL migration tests and slow tests are NOT in local default gates. They run in separate CI workflows.
|
||||||
gates. They run in separate CI workflows. Frontend Playwright tests live under
|
|
||||||
`web/tests/e2e` and are documented in `web/README.md`.
|
|
||||||
|
|
||||||
### Developer Workflow
|
### Developer Workflow
|
||||||
|
|
||||||
@@ -34,9 +28,6 @@ make test-all-local
|
|||||||
bash scripts/test-quick.sh # ~2 min
|
bash scripts/test-quick.sh # ~2 min
|
||||||
bash scripts/test-integration-fast.sh # ~3 min
|
bash scripts/test-integration-fast.sh # ~3 min
|
||||||
bash scripts/test-coverage.sh # ~8 min
|
bash scripts/test-coverage.sh # ~8 min
|
||||||
uv run --python 3.12 pytest tests/e2e -q --tb=short
|
|
||||||
uv run --python 3.12 pytest tests/integration_tests -q --tb=short
|
|
||||||
cd web && pnpm test:e2e
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Coverage Baseline
|
### Coverage Baseline
|
||||||
@@ -79,12 +70,6 @@ tests/
|
|||||||
│ └── persistence/ # Database/persistence tests
|
│ └── persistence/ # Database/persistence tests
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ └── test_migrations.py # Alembic migration tests
|
│ └── test_migrations.py # Alembic migration tests
|
||||||
├── e2e/ # Real LangBot startup E2E tests
|
|
||||||
│ ├── conftest.py
|
|
||||||
│ ├── test_startup.py
|
|
||||||
│ └── utils/
|
|
||||||
├── integration_tests/ # Container-backed integration tests
|
|
||||||
│ └── box/ # Box runtime and MCP process tests
|
|
||||||
├── smoke/ # Smoke tests (quick validation)
|
├── smoke/ # Smoke tests (quick validation)
|
||||||
│ └── test_fake_message_flow.py
|
│ └── test_fake_message_flow.py
|
||||||
├── unit_tests/ # Unit tests
|
├── unit_tests/ # Unit tests
|
||||||
@@ -318,44 +303,6 @@ These tests:
|
|||||||
- Test prevent_default, exception handling, and full message flow
|
- Test prevent_default, exception handling, and full message flow
|
||||||
- Do not require real LLM provider keys
|
- Do not require real LLM provider keys
|
||||||
|
|
||||||
### Running backend E2E startup tests
|
|
||||||
|
|
||||||
Backend E2E tests start a real LangBot process with a generated minimal
|
|
||||||
`data/config.yaml`, SQLite database, local storage, and embedded Chroma path.
|
|
||||||
They do not require provider keys or external services.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run --python 3.12 pytest tests/e2e -q --tb=short
|
|
||||||
```
|
|
||||||
|
|
||||||
These tests verify startup orchestration, migrations, API route registration,
|
|
||||||
and the minimal no-LLM startup path. The E2E process manager disables ambient
|
|
||||||
proxy variables for subprocess startup and uses direct localhost HTTP clients,
|
|
||||||
so local proxy settings should not affect the health checks.
|
|
||||||
|
|
||||||
### Running Box integration tests
|
|
||||||
|
|
||||||
Box integration tests exercise the real sandbox runtime path, including command
|
|
||||||
execution, session persistence, managed process WebSocket attachment, and
|
|
||||||
cleanup behavior.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run --python 3.12 pytest tests/integration_tests -q --tb=short
|
|
||||||
```
|
|
||||||
|
|
||||||
These tests require a working Docker or Podman runtime. In CI, the dedicated
|
|
||||||
Box integration job checks Docker availability before running the tests.
|
|
||||||
|
|
||||||
### Running frontend E2E tests
|
|
||||||
|
|
||||||
Frontend E2E tests live in `web/tests/e2e` and use Playwright. They start Vite
|
|
||||||
and mock the LangBot backend and Space APIs, so no backend process is required.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd web
|
|
||||||
pnpm test:e2e
|
|
||||||
```
|
|
||||||
|
|
||||||
### Known Issues
|
### Known Issues
|
||||||
|
|
||||||
Some tests may encounter circular import errors. This is a known issue with the current module structure. The test infrastructure is designed to work around this using lazy imports, but if you encounter issues:
|
Some tests may encounter circular import errors. This is a known issue with the current module structure. The test infrastructure is designed to work around this using lazy imports, but if you encounter issues:
|
||||||
@@ -373,9 +320,6 @@ Tests are automatically run on:
|
|||||||
- Push to master/develop branches
|
- Push to master/develop branches
|
||||||
|
|
||||||
The workflow runs tests on Python 3.11, 3.12, and 3.13 to ensure compatibility.
|
The workflow runs tests on Python 3.11, 3.12, and 3.13 to ensure compatibility.
|
||||||
Startup E2E and Box integration tests run as separate Python 3.12 jobs because
|
|
||||||
they exercise process/container behavior instead of pure Python compatibility.
|
|
||||||
Frontend Playwright smoke tests run in `.github/workflows/frontend-tests.yml`.
|
|
||||||
|
|
||||||
## Adding New Tests
|
## Adding New Tests
|
||||||
|
|
||||||
@@ -462,4 +406,4 @@ Check that you're mocking at the right level and using `AsyncMock` for async fun
|
|||||||
- [ ] Add E2E tests
|
- [ ] Add E2E tests
|
||||||
- [ ] Add performance benchmarks
|
- [ ] Add performance benchmarks
|
||||||
- [ ] Add mutation testing for better coverage quality
|
- [ ] Add mutation testing for better coverage quality
|
||||||
- [ ] Add property-based testing with Hypothesis
|
- [ ] Add property-based testing with Hypothesis
|
||||||
@@ -92,11 +92,11 @@ def e2e_client(e2e_port, langbot_process):
|
|||||||
|
|
||||||
base_url = f'http://127.0.0.1:{e2e_port}'
|
base_url = f'http://127.0.0.1:{e2e_port}'
|
||||||
|
|
||||||
with httpx.Client(base_url=base_url, timeout=10.0, trust_env=False) as client:
|
with httpx.Client(base_url=base_url, timeout=10.0) as client:
|
||||||
yield client
|
yield client
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='session')
|
@pytest.fixture(scope='session')
|
||||||
def e2e_db_path(e2e_tmpdir):
|
def e2e_db_path(e2e_tmpdir):
|
||||||
"""Path to SQLite database file."""
|
"""Path to SQLite database file."""
|
||||||
return e2e_tmpdir / 'data' / 'langbot.db'
|
return e2e_tmpdir / 'data' / 'langbot.db'
|
||||||
@@ -38,7 +38,7 @@ class TestStartupFlow:
|
|||||||
# System info should contain version info
|
# System info should contain version info
|
||||||
assert 'version' in data['data'] or 'edition' in data['data']
|
assert 'version' in data['data'] or 'edition' in data['data']
|
||||||
|
|
||||||
def test_database_initialized(self, langbot_process, e2e_db_path):
|
def test_database_initialized(self, e2e_db_path):
|
||||||
"""Verify SQLite database was created and initialized."""
|
"""Verify SQLite database was created and initialized."""
|
||||||
assert e2e_db_path.exists()
|
assert e2e_db_path.exists()
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ class TestStartupFlow:
|
|||||||
"""Test auth endpoint."""
|
"""Test auth endpoint."""
|
||||||
# First startup may allow initial setup
|
# First startup may allow initial setup
|
||||||
response = e2e_client.post('/api/v1/user/auth', json={
|
response = e2e_client.post('/api/v1/user/auth', json={
|
||||||
'user': 'admin',
|
'username': 'admin',
|
||||||
'password': 'admin',
|
'password': 'admin',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ class TestStartupStages:
|
|||||||
# If API responds on e2e_port, config was loaded
|
# If API responds on e2e_port, config was loaded
|
||||||
assert e2e_client.get('/api/v1/system/info').status_code == 200
|
assert e2e_client.get('/api/v1/system/info').status_code == 200
|
||||||
|
|
||||||
def test_migrations_applied(self, langbot_process, e2e_db_path):
|
def test_migrations_applied(self, e2e_db_path):
|
||||||
"""Verify database migrations were applied."""
|
"""Verify database migrations were applied."""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
conn = sqlite3.connect(str(e2e_db_path))
|
conn = sqlite3.connect(str(e2e_db_path))
|
||||||
|
|||||||
@@ -44,17 +44,6 @@ class LangBotProcess:
|
|||||||
# Prepare environment
|
# Prepare environment
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env['PYTHONPATH'] = str(self.project_root / 'src')
|
env['PYTHONPATH'] = str(self.project_root / 'src')
|
||||||
for proxy_key in (
|
|
||||||
'HTTP_PROXY',
|
|
||||||
'HTTPS_PROXY',
|
|
||||||
'ALL_PROXY',
|
|
||||||
'http_proxy',
|
|
||||||
'https_proxy',
|
|
||||||
'all_proxy',
|
|
||||||
):
|
|
||||||
env.pop(proxy_key, None)
|
|
||||||
env['NO_PROXY'] = '127.0.0.1,localhost'
|
|
||||||
env['no_proxy'] = '127.0.0.1,localhost'
|
|
||||||
|
|
||||||
# Set API port via environment variable
|
# Set API port via environment variable
|
||||||
env['API__PORT'] = str(self.port)
|
env['API__PORT'] = str(self.port)
|
||||||
@@ -124,8 +113,6 @@ precision = 2
|
|||||||
r = httpx.get(
|
r = httpx.get(
|
||||||
f'http://127.0.0.1:{self.port}/api/v1/system/info',
|
f'http://127.0.0.1:{self.port}/api/v1/system/info',
|
||||||
timeout=2.0,
|
timeout=2.0,
|
||||||
follow_redirects=False,
|
|
||||||
trust_env=False,
|
|
||||||
)
|
)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
logger.info(f'LangBot started successfully on port {self.port}')
|
logger.info(f'LangBot started successfully on port {self.port}')
|
||||||
@@ -198,8 +185,6 @@ precision = 2
|
|||||||
r = httpx.get(
|
r = httpx.get(
|
||||||
f'http://127.0.0.1:{self.port}/api/v1/system/info',
|
f'http://127.0.0.1:{self.port}/api/v1/system/info',
|
||||||
timeout=5.0,
|
timeout=5.0,
|
||||||
follow_redirects=False,
|
|
||||||
trust_env=False,
|
|
||||||
)
|
)
|
||||||
return r.status_code == 200
|
return r.status_code == 200
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -216,4 +201,4 @@ def find_project_root() -> Path:
|
|||||||
return parent
|
return parent
|
||||||
|
|
||||||
# Fallback to LangBot-test-build directory
|
# Fallback to LangBot-test-build directory
|
||||||
return Path('/home/glwuy/langbot-app/LangBot-test-build')
|
return Path('/home/glwuy/langbot-app/LangBot-test-build')
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
@@ -88,6 +89,28 @@ def test_token_manager_next_token_ignores_empty_token_list():
|
|||||||
assert token_mgr.using_token_index == 0
|
assert token_mgr.using_token_index == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_model_manager_initialize_skips_space_sync_after_timeout():
|
||||||
|
ap = SimpleNamespace()
|
||||||
|
ap.discover = SimpleNamespace(get_components_by_kind=Mock(return_value=[]))
|
||||||
|
ap.instance_config = SimpleNamespace(data={'space': {'models_sync_timeout': 0.01}})
|
||||||
|
ap.logger = Mock()
|
||||||
|
|
||||||
|
mgr = ModelManager(ap)
|
||||||
|
mgr.load_models_from_db = AsyncMock()
|
||||||
|
|
||||||
|
async def slow_sync():
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
mgr.sync_new_models_from_space = AsyncMock(side_effect=slow_sync)
|
||||||
|
|
||||||
|
await mgr.initialize()
|
||||||
|
|
||||||
|
mgr.load_models_from_db.assert_awaited_once()
|
||||||
|
mgr.sync_new_models_from_space.assert_awaited_once()
|
||||||
|
ap.logger.warning.assert_any_call('LangBot Space model sync timed out after 0.01s, skipping startup sync.')
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline():
|
async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline():
|
||||||
from langbot.pkg.api.http.service.model import LLMModelsService
|
from langbot.pkg.api.http.service.model import LLMModelsService
|
||||||
|
|||||||
2
web/.gitignore
vendored
2
web/.gitignore
vendored
@@ -12,8 +12,6 @@
|
|||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
/playwright-report
|
|
||||||
/test-results
|
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/dist/
|
/dist/
|
||||||
|
|||||||
@@ -1,13 +1,3 @@
|
|||||||
# Debug LangBot Frontend
|
# Debug LangBot Frontend
|
||||||
|
|
||||||
Please refer to the [Development Guide](https://link.langbot.app/en/docs/dev-config) for more information.
|
Please refer to the [Development Guide](https://link.langbot.app/en/docs/dev-config) for more information.
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
Run the frontend smoke tests without a backend process:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm test:e2e
|
|
||||||
```
|
|
||||||
|
|
||||||
The Playwright suite starts Vite and mocks the LangBot backend and Space APIs.
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test:e2e": "playwright test",
|
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
@@ -87,7 +86,6 @@
|
|||||||
"zod": "^3.24.4"
|
"zod": "^3.24.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.61.0",
|
|
||||||
"@types/debug": "^4.1.12",
|
"@types/debug": "^4.1.12",
|
||||||
"@types/estree": "^1.0.8",
|
"@types/estree": "^1.0.8",
|
||||||
"@types/estree-jsx": "^1.0.5",
|
"@types/estree-jsx": "^1.0.5",
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: './tests/e2e',
|
|
||||||
fullyParallel: true,
|
|
||||||
forbidOnly: !!process.env.CI,
|
|
||||||
retries: process.env.CI ? 1 : 0,
|
|
||||||
reporter: process.env.CI ? [['github'], ['list']] : 'list',
|
|
||||||
use: {
|
|
||||||
baseURL: 'http://127.0.0.1:4173',
|
|
||||||
trace: 'on-first-retry',
|
|
||||||
},
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: 'chromium',
|
|
||||||
use: { ...devices['Desktop Chrome'] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
webServer: {
|
|
||||||
command: 'pnpm exec vite --host 127.0.0.1 --port 4173',
|
|
||||||
url: 'http://127.0.0.1:4173',
|
|
||||||
reuseExistingServer: !process.env.CI,
|
|
||||||
timeout: 120_000,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
35
web/pnpm-lock.yaml
generated
35
web/pnpm-lock.yaml
generated
@@ -192,9 +192,6 @@ dependencies:
|
|||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@playwright/test':
|
|
||||||
specifier: ^1.61.0
|
|
||||||
version: 1.61.0
|
|
||||||
'@types/debug':
|
'@types/debug':
|
||||||
specifier: ^4.1.12
|
specifier: ^4.1.12
|
||||||
version: 4.1.12
|
version: 4.1.12
|
||||||
@@ -532,14 +529,6 @@ packages:
|
|||||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@playwright/test@1.61.0:
|
|
||||||
resolution: {integrity: sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
hasBin: true
|
|
||||||
dependencies:
|
|
||||||
playwright: 1.61.0
|
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@radix-ui/number@1.1.1:
|
/@radix-ui/number@1.1.1:
|
||||||
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
||||||
dev: false
|
dev: false
|
||||||
@@ -3215,14 +3204,6 @@ packages:
|
|||||||
engines: {node: '>=0.4.x'}
|
engines: {node: '>=0.4.x'}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/fsevents@2.3.2:
|
|
||||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
|
||||||
os: [darwin]
|
|
||||||
requiresBuild: true
|
|
||||||
dev: true
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
/fsevents@2.3.3:
|
/fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -4959,22 +4940,6 @@ packages:
|
|||||||
hasBin: true
|
hasBin: true
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/playwright-core@1.61.0:
|
|
||||||
resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
hasBin: true
|
|
||||||
dev: true
|
|
||||||
|
|
||||||
/playwright@1.61.0:
|
|
||||||
resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
hasBin: true
|
|
||||||
dependencies:
|
|
||||||
playwright-core: 1.61.0
|
|
||||||
optionalDependencies:
|
|
||||||
fsevents: 2.3.2
|
|
||||||
dev: true
|
|
||||||
|
|
||||||
/pngjs@5.0.0:
|
/pngjs@5.0.0:
|
||||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
|
|||||||
@@ -1,417 +0,0 @@
|
|||||||
import { Page, Route } from '@playwright/test';
|
|
||||||
|
|
||||||
type JsonRecord = Record<string, unknown>;
|
|
||||||
|
|
||||||
interface SkillMock {
|
|
||||||
name: string;
|
|
||||||
display_name: string;
|
|
||||||
description: string;
|
|
||||||
instructions: string;
|
|
||||||
package_root: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LangBotApiMockState {
|
|
||||||
skills: SkillMock[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function ok(data: unknown) {
|
|
||||||
return {
|
|
||||||
code: 0,
|
|
||||||
message: 'ok',
|
|
||||||
data,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fulfillJson(route: Route, data: unknown) {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify(ok(data)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function routePath(route: Route) {
|
|
||||||
return new URL(route.request().url()).pathname;
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyMonitoringData() {
|
|
||||||
return {
|
|
||||||
overview: {
|
|
||||||
total_messages: 0,
|
|
||||||
llm_calls: 0,
|
|
||||||
embedding_calls: 0,
|
|
||||||
model_calls: 0,
|
|
||||||
success_rate: 0,
|
|
||||||
active_sessions: 0,
|
|
||||||
},
|
|
||||||
messages: [],
|
|
||||||
llmCalls: [],
|
|
||||||
embeddingCalls: [],
|
|
||||||
sessions: [],
|
|
||||||
errors: [],
|
|
||||||
totalCount: {
|
|
||||||
messages: 0,
|
|
||||||
llmCalls: 0,
|
|
||||||
embeddingCalls: 0,
|
|
||||||
sessions: 0,
|
|
||||||
errors: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyTokenStatistics() {
|
|
||||||
return {
|
|
||||||
summary: {
|
|
||||||
total_calls: 0,
|
|
||||||
success_calls: 0,
|
|
||||||
error_calls: 0,
|
|
||||||
total_input_tokens: 0,
|
|
||||||
total_output_tokens: 0,
|
|
||||||
total_tokens: 0,
|
|
||||||
total_cost: 0,
|
|
||||||
avg_tokens_per_call: 0,
|
|
||||||
avg_duration_ms: 0,
|
|
||||||
avg_tokens_per_second: 0,
|
|
||||||
zero_token_success_calls: 0,
|
|
||||||
},
|
|
||||||
by_model: [],
|
|
||||||
timeseries: [],
|
|
||||||
bucket: 'day',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeSkill(data: JsonRecord): SkillMock {
|
|
||||||
return {
|
|
||||||
name: String(data.name || ''),
|
|
||||||
display_name: String(data.display_name || ''),
|
|
||||||
description: String(data.description || ''),
|
|
||||||
instructions: String(data.instructions || ''),
|
|
||||||
package_root: String(data.package_root || ''),
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
|
||||||
const request = route.request();
|
|
||||||
const url = new URL(request.url());
|
|
||||||
const path = url.pathname;
|
|
||||||
const method = request.method();
|
|
||||||
|
|
||||||
if (path === '/api/v1/system/info') {
|
|
||||||
return fulfillJson(route, {
|
|
||||||
debug: false,
|
|
||||||
version: 'frontend-smoke',
|
|
||||||
edition: 'community',
|
|
||||||
cloud_service_url: 'https://space.langbot.app',
|
|
||||||
enable_marketplace: true,
|
|
||||||
allow_modify_login_info: true,
|
|
||||||
disable_models_service: false,
|
|
||||||
limitation: {
|
|
||||||
max_bots: -1,
|
|
||||||
max_pipelines: -1,
|
|
||||||
max_extensions: -1,
|
|
||||||
},
|
|
||||||
outbound_ips: [],
|
|
||||||
wizard_status: 'completed',
|
|
||||||
wizard_progress: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/user/account-info') {
|
|
||||||
return fulfillJson(route, {
|
|
||||||
initialized: true,
|
|
||||||
account_type: 'local',
|
|
||||||
has_password: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/user/check-token') {
|
|
||||||
return fulfillJson(route, { token: '' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/user/auth') {
|
|
||||||
return fulfillJson(route, { token: 'playwright-token' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/user/info') {
|
|
||||||
return fulfillJson(route, {
|
|
||||||
user: 'admin@example.com',
|
|
||||||
account_type: 'local',
|
|
||||||
has_password: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/user/space-credits') {
|
|
||||||
return fulfillJson(route, { credits: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/platform/bots') {
|
|
||||||
return fulfillJson(route, { bots: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/pipelines') {
|
|
||||||
return fulfillJson(route, { pipelines: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/knowledge/bases') {
|
|
||||||
return fulfillJson(route, { bases: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/knowledge/migration/status') {
|
|
||||||
return fulfillJson(route, {
|
|
||||||
needed: false,
|
|
||||||
internal_kb_count: 0,
|
|
||||||
external_kb_count: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/plugins') {
|
|
||||||
return fulfillJson(route, { plugins: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/extensions') {
|
|
||||||
return fulfillJson(route, { extensions: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/mcp/servers') {
|
|
||||||
return fulfillJson(route, { servers: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/skills') {
|
|
||||||
if (method === 'POST') {
|
|
||||||
const skill = makeSkill(
|
|
||||||
JSON.parse(request.postData() || '{}') as JsonRecord,
|
|
||||||
);
|
|
||||||
state.skills = [
|
|
||||||
...state.skills.filter((item) => item.name !== skill.name),
|
|
||||||
skill,
|
|
||||||
];
|
|
||||||
return fulfillJson(route, { skill });
|
|
||||||
}
|
|
||||||
|
|
||||||
return fulfillJson(route, { skills: state.skills });
|
|
||||||
}
|
|
||||||
|
|
||||||
const skillFileMatch = path.match(
|
|
||||||
/^\/api\/v1\/skills\/([^/]+)\/files\/(.+)$/,
|
|
||||||
);
|
|
||||||
if (skillFileMatch) {
|
|
||||||
const skillName = decodeURIComponent(skillFileMatch[1]);
|
|
||||||
const filePath = decodeURIComponent(skillFileMatch[2]);
|
|
||||||
const skill = state.skills.find((item) => item.name === skillName);
|
|
||||||
return fulfillJson(route, {
|
|
||||||
skill: { name: skillName },
|
|
||||||
path: filePath,
|
|
||||||
content: skill?.instructions || '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const skillFilesMatch = path.match(/^\/api\/v1\/skills\/([^/]+)\/files$/);
|
|
||||||
if (skillFilesMatch) {
|
|
||||||
const skillName = decodeURIComponent(skillFilesMatch[1]);
|
|
||||||
return fulfillJson(route, {
|
|
||||||
skill: { name: skillName },
|
|
||||||
base_path: '.',
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
path: 'SKILL.md',
|
|
||||||
name: 'SKILL.md',
|
|
||||||
is_dir: false,
|
|
||||||
size: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
truncated: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const skillMatch = path.match(/^\/api\/v1\/skills\/([^/]+)$/);
|
|
||||||
if (skillMatch) {
|
|
||||||
const skillName = decodeURIComponent(skillMatch[1]);
|
|
||||||
const skill = state.skills.find((item) => item.name === skillName) || {
|
|
||||||
name: skillName,
|
|
||||||
display_name: '',
|
|
||||||
description: '',
|
|
||||||
instructions: '',
|
|
||||||
package_root: '',
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
return fulfillJson(route, { skill });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/system/status/plugin-system') {
|
|
||||||
return fulfillJson(route, {
|
|
||||||
is_enable: true,
|
|
||||||
is_connected: true,
|
|
||||||
plugin_connector_error: '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/plugins/debug-info') {
|
|
||||||
return fulfillJson(route, {
|
|
||||||
debug_url: 'ws://127.0.0.1:5300/plugin/debug',
|
|
||||||
plugin_debug_key: 'test-debug-key',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/box/status') {
|
|
||||||
return fulfillJson(route, {
|
|
||||||
available: true,
|
|
||||||
enabled: true,
|
|
||||||
profile: 'playwright',
|
|
||||||
recent_error_count: 0,
|
|
||||||
active_sessions: 0,
|
|
||||||
managed_processes: 0,
|
|
||||||
session_ttl_sec: 3600,
|
|
||||||
backend: {
|
|
||||||
name: 'playwright',
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/box/sessions') {
|
|
||||||
return fulfillJson(route, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/monitoring/data') {
|
|
||||||
return fulfillJson(route, emptyMonitoringData());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/monitoring/overview') {
|
|
||||||
return fulfillJson(route, emptyMonitoringData().overview);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/monitoring/token-statistics') {
|
|
||||||
return fulfillJson(route, emptyTokenStatistics());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/monitoring/feedback/stats') {
|
|
||||||
return fulfillJson(route, {
|
|
||||||
total_feedback: 0,
|
|
||||||
total_likes: 0,
|
|
||||||
total_dislikes: 0,
|
|
||||||
satisfaction_rate: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/monitoring/feedback') {
|
|
||||||
return fulfillJson(route, { feedback: [], total: 0 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/survey/pending') {
|
|
||||||
return fulfillJson(route, { survey: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/system/tasks') {
|
|
||||||
return fulfillJson(route, { tasks: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
path === '/api/v1/marketplace/plugins' ||
|
|
||||||
path === '/api/v1/marketplace/plugins/search' ||
|
|
||||||
path === '/api/v1/marketplace/extensions/search' ||
|
|
||||||
path === '/api/v1/marketplace/mcps/search' ||
|
|
||||||
path === '/api/v1/marketplace/skills/search'
|
|
||||||
) {
|
|
||||||
return fulfillJson(route, { plugins: [], total: 0 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/marketplace/tags') {
|
|
||||||
return fulfillJson(route, { tags: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/marketplace/recommendation-lists') {
|
|
||||||
return fulfillJson(route, { lists: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/dist/info/releases') {
|
|
||||||
return fulfillJson(route, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/dist/info/repo') {
|
|
||||||
return fulfillJson(route, {
|
|
||||||
repo: {
|
|
||||||
stargazers_count: 0,
|
|
||||||
forks_count: 0,
|
|
||||||
open_issues_count: 0,
|
|
||||||
},
|
|
||||||
contributors: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await fulfillJson(route, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCloudApi(route: Route) {
|
|
||||||
const path = routePath(route);
|
|
||||||
|
|
||||||
if (
|
|
||||||
path === '/api/v1/marketplace/plugins' ||
|
|
||||||
path === '/api/v1/marketplace/plugins/search' ||
|
|
||||||
path === '/api/v1/marketplace/extensions/search' ||
|
|
||||||
path === '/api/v1/marketplace/mcps/search' ||
|
|
||||||
path === '/api/v1/marketplace/skills/search'
|
|
||||||
) {
|
|
||||||
return fulfillJson(route, { plugins: [], total: 0 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/marketplace/tags') {
|
|
||||||
return fulfillJson(route, { tags: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/marketplace/recommendation-lists') {
|
|
||||||
return fulfillJson(route, { lists: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/dist/info/releases') {
|
|
||||||
return fulfillJson(route, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/v1/dist/info/repo') {
|
|
||||||
return fulfillJson(route, {
|
|
||||||
repo: {
|
|
||||||
stargazers_count: 0,
|
|
||||||
forks_count: 0,
|
|
||||||
open_issues_count: 0,
|
|
||||||
},
|
|
||||||
contributors: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await fulfillJson(route, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function installLangBotApiMocks(
|
|
||||||
page: Page,
|
|
||||||
options: { authenticated?: boolean; storage?: JsonRecord } = {},
|
|
||||||
) {
|
|
||||||
const { authenticated = false, storage = {} } = options;
|
|
||||||
const state: LangBotApiMockState = {
|
|
||||||
skills: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
await page.addInitScript(
|
|
||||||
({ authenticated, storage }) => {
|
|
||||||
localStorage.setItem('langbot_language', 'en-US');
|
|
||||||
localStorage.setItem('extensions_group_by_type', 'false');
|
|
||||||
|
|
||||||
if (authenticated) {
|
|
||||||
localStorage.setItem('token', 'playwright-token');
|
|
||||||
localStorage.setItem('userEmail', 'admin@example.com');
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem('token');
|
|
||||||
localStorage.removeItem('userEmail');
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(storage)) {
|
|
||||||
localStorage.setItem(key, String(value));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ authenticated, storage },
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.route('**/api/v1/**', (route) => handleBackendApi(route, state));
|
|
||||||
await page.route('https://space.langbot.app/**', handleCloudApi);
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
|
||||||
|
|
||||||
const appRoutes = [
|
|
||||||
{
|
|
||||||
path: '/home/bots',
|
|
||||||
heading: 'Bots',
|
|
||||||
bodyText: 'Select a bot from the sidebar',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/home/pipelines',
|
|
||||||
heading: 'Pipelines',
|
|
||||||
bodyText: 'Select a pipeline from the sidebar',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/home/extensions',
|
|
||||||
heading: 'Extensions',
|
|
||||||
bodyText: 'No extensions installed',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/home/mcp',
|
|
||||||
heading: 'MCP',
|
|
||||||
bodyText: 'Select an MCP server from the sidebar',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/home/knowledge',
|
|
||||||
heading: 'Knowledge',
|
|
||||||
bodyText: 'Select a knowledge base from the sidebar',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
test.describe('authenticated app shell', () => {
|
|
||||||
for (const route of appRoutes) {
|
|
||||||
test(`${route.path} renders without a backend process`, async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await installLangBotApiMocks(page, { authenticated: true });
|
|
||||||
|
|
||||||
await page.goto(route.path);
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`${route.path}$`));
|
|
||||||
await expect(page.getByText('Home').first()).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.getByRole('button', { name: 'Dashboard' }),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(page.getByText('Extensions').first()).toBeVisible();
|
|
||||||
await expect(page.getByText(route.heading).first()).toBeVisible();
|
|
||||||
await expect(page.getByText(route.bodyText)).toBeVisible();
|
|
||||||
await expect(page.getByText('Backend unavailable')).toHaveCount(0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('/home/monitoring loads dashboard data from mocked APIs', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await installLangBotApiMocks(page, { authenticated: true });
|
|
||||||
|
|
||||||
await page.goto('/home/monitoring');
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/home\/monitoring$/);
|
|
||||||
await expect(page.getByText('Total Messages').first()).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.getByRole('tab', { name: 'Message Records' }),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.getByRole('tab', { name: 'Token Monitoring' }),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByRole('tab', { name: 'Token Monitoring' }).click();
|
|
||||||
await expect(
|
|
||||||
page.getByText('No token usage in the selected time range'),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(page.getByText('Unable to connect to server')).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('/home/extensions shows plugin debug information from the backend', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await installLangBotApiMocks(page, { authenticated: true });
|
|
||||||
|
|
||||||
await page.goto('/home/extensions');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Debug Info' }).click();
|
|
||||||
|
|
||||||
await expect(page.getByText('Plugin Debug Information')).toBeVisible();
|
|
||||||
await expect(page.getByRole('textbox').nth(0)).toHaveValue(
|
|
||||||
'ws://127.0.0.1:5300/plugin/debug',
|
|
||||||
);
|
|
||||||
await expect(page.getByRole('textbox').nth(1)).toHaveValue(
|
|
||||||
'test-debug-key',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('/home/skills?action=create creates a manual skill', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await installLangBotApiMocks(page, { authenticated: true });
|
|
||||||
|
|
||||||
await page.goto('/home/skills?action=create');
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/home\/skills\?action=create$/);
|
|
||||||
await expect(page.getByText('Create Skill').first()).toBeVisible();
|
|
||||||
await expect(page.getByText('Import Local Skill Directory')).toBeVisible();
|
|
||||||
|
|
||||||
const saveButton = page.getByRole('button', { name: 'Save' });
|
|
||||||
await expect(saveButton).toBeEnabled();
|
|
||||||
await saveButton.click();
|
|
||||||
await expect(page.getByText('Skill name cannot be empty')).toBeVisible();
|
|
||||||
|
|
||||||
await page.locator('#display_name').fill('Daily Summary');
|
|
||||||
await page.locator('#name').fill('daily_summary');
|
|
||||||
await page
|
|
||||||
.locator('#description')
|
|
||||||
.fill('Summarizes the current conversation for handoff.');
|
|
||||||
await page
|
|
||||||
.locator('#instructions')
|
|
||||||
.fill('Summarize the conversation in five concise bullet points.');
|
|
||||||
await saveButton.click();
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/home\/skills\?id=daily_summary$/);
|
|
||||||
await expect(
|
|
||||||
page.getByRole('heading', { name: 'Daily Summary' }),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(page.locator('#name')).toHaveValue('daily_summary');
|
|
||||||
await expect(page.locator('#description')).toHaveValue(
|
|
||||||
'Summarizes the current conversation for handoff.',
|
|
||||||
);
|
|
||||||
await expect(page.locator('#instructions')).toHaveValue(
|
|
||||||
'Summarize the conversation in five concise bullet points.',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
|
||||||
|
|
||||||
test('local account login reaches the authenticated home shell', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await installLangBotApiMocks(page);
|
|
||||||
|
|
||||||
await page.goto('/login');
|
|
||||||
|
|
||||||
await expect(page.getByText('Welcome')).toBeVisible();
|
|
||||||
await page.getByPlaceholder('Enter email address').fill('admin@example.com');
|
|
||||||
await page.getByPlaceholder('Enter password').fill('password');
|
|
||||||
await page.getByRole('button', { name: 'Login with password' }).click();
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/home$/);
|
|
||||||
await expect(page.getByText('Home').first()).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: 'Dashboard' })).toBeVisible();
|
|
||||||
await expect(page.getByText('Total Messages').first()).toBeVisible();
|
|
||||||
await expect(page.getByText('Unable to connect to server')).toHaveCount(0);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user