Compare commits

...

1 Commits

Author SHA1 Message Date
huanghuoguoguo
e27e26b071 test: add frontend smoke and backend e2e CI 2026-06-16 10:54:57 +08:00
15 changed files with 835 additions and 11 deletions

46
.github/workflows/frontend-tests.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
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

View File

@@ -29,7 +29,7 @@ jobs:
run: uv sync --dev
- name: Run ruff check
run: uv run ruff check src
run: uv run ruff check src/langbot/ tests/ --output-format=concise
- name: Run ruff format
run: uv run ruff format src --check

View File

@@ -84,6 +84,67 @@ jobs:
echo "" >> $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:
name: Coverage Gate
runs-on: ubuntu-latest
@@ -129,4 +190,4 @@ jobs:
echo "## Coverage Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Threshold: 18%" >> $GITHUB_STEP_SUMMARY
echo "Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
echo "Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY

View File

@@ -1,6 +1,7 @@
# LangBot Test Suite
This directory contains the test suite for LangBot, with a focus on comprehensive unit testing of pipeline stages.
This directory contains the LangBot backend test suite, including unit tests,
integration tests, startup E2E tests, and container-backed Box runtime tests.
## Quality Gate Layers
@@ -10,10 +11,15 @@ 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 |
| **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 |
| **Full Local** | `make test-all-local` | Quick + Integration + Coverage | Before major changes |
**Note**: PostgreSQL migration tests and slow tests are NOT in local default gates. They run in separate CI workflows.
**Note**: PostgreSQL migration tests and slow tests are NOT in local default
gates. They run in separate CI workflows. Frontend Playwright tests live under
`web/tests/e2e` and are documented in `web/README.md`.
### Developer Workflow
@@ -28,6 +34,9 @@ make test-all-local
bash scripts/test-quick.sh # ~2 min
bash scripts/test-integration-fast.sh # ~3 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
@@ -70,6 +79,12 @@ tests/
│ └── persistence/ # Database/persistence tests
│ ├── __init__.py
│ └── 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)
│ └── test_fake_message_flow.py
├── unit_tests/ # Unit tests
@@ -303,6 +318,44 @@ These tests:
- Test prevent_default, exception handling, and full message flow
- 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
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:
@@ -320,6 +373,9 @@ Tests are automatically run on:
- Push to master/develop branches
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
@@ -406,4 +462,4 @@ Check that you're mocking at the right level and using `AsyncMock` for async fun
- [ ] Add E2E tests
- [ ] Add performance benchmarks
- [ ] Add mutation testing for better coverage quality
- [ ] Add property-based testing with Hypothesis
- [ ] Add property-based testing with Hypothesis

View File

@@ -92,11 +92,11 @@ def e2e_client(e2e_port, langbot_process):
base_url = f'http://127.0.0.1:{e2e_port}'
with httpx.Client(base_url=base_url, timeout=10.0) as client:
with httpx.Client(base_url=base_url, timeout=10.0, trust_env=False) as client:
yield client
@pytest.fixture(scope='session')
def e2e_db_path(e2e_tmpdir):
"""Path to SQLite database file."""
return e2e_tmpdir / 'data' / 'langbot.db'
return e2e_tmpdir / 'data' / 'langbot.db'

View File

@@ -38,7 +38,7 @@ class TestStartupFlow:
# System info should contain version info
assert 'version' in data['data'] or 'edition' in data['data']
def test_database_initialized(self, e2e_db_path):
def test_database_initialized(self, langbot_process, e2e_db_path):
"""Verify SQLite database was created and initialized."""
assert e2e_db_path.exists()
@@ -75,7 +75,7 @@ class TestStartupFlow:
"""Test auth endpoint."""
# First startup may allow initial setup
response = e2e_client.post('/api/v1/user/auth', json={
'username': 'admin',
'user': 'admin',
'password': 'admin',
})
@@ -94,7 +94,7 @@ class TestStartupStages:
# If API responds on e2e_port, config was loaded
assert e2e_client.get('/api/v1/system/info').status_code == 200
def test_migrations_applied(self, e2e_db_path):
def test_migrations_applied(self, langbot_process, e2e_db_path):
"""Verify database migrations were applied."""
import sqlite3
conn = sqlite3.connect(str(e2e_db_path))

View File

@@ -44,6 +44,17 @@ class LangBotProcess:
# Prepare environment
env = os.environ.copy()
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
env['API__PORT'] = str(self.port)
@@ -113,6 +124,8 @@ precision = 2
r = httpx.get(
f'http://127.0.0.1:{self.port}/api/v1/system/info',
timeout=2.0,
follow_redirects=False,
trust_env=False,
)
if r.status_code == 200:
logger.info(f'LangBot started successfully on port {self.port}')
@@ -185,6 +198,8 @@ precision = 2
r = httpx.get(
f'http://127.0.0.1:{self.port}/api/v1/system/info',
timeout=5.0,
follow_redirects=False,
trust_env=False,
)
return r.status_code == 200
except Exception:
@@ -201,4 +216,4 @@ def find_project_root() -> Path:
return parent
# Fallback to LangBot-test-build directory
return Path('/home/glwuy/langbot-app/LangBot-test-build')
return Path('/home/glwuy/langbot-app/LangBot-test-build')

2
web/.gitignore vendored
View File

@@ -12,6 +12,8 @@
# testing
/coverage
/playwright-report
/test-results
# next.js
/dist/

View File

@@ -1,3 +1,13 @@
# Debug LangBot Frontend
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.

View File

@@ -6,6 +6,7 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test:e2e": "playwright test",
"lint": "eslint .",
"format": "prettier --write ."
},
@@ -86,6 +87,7 @@
"zod": "^3.24.4"
},
"devDependencies": {
"@playwright/test": "^1.61.0",
"@types/debug": "^4.1.12",
"@types/estree": "^1.0.8",
"@types/estree-jsx": "^1.0.5",

25
web/playwright.config.ts Normal file
View File

@@ -0,0 +1,25 @@
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
View File

@@ -192,6 +192,9 @@ dependencies:
version: 3.25.76
devDependencies:
'@playwright/test':
specifier: ^1.61.0
version: 1.61.0
'@types/debug':
specifier: ^4.1.12
version: 4.1.12
@@ -529,6 +532,14 @@ packages:
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
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:
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
dev: false
@@ -3204,6 +3215,14 @@ packages:
engines: {node: '>=0.4.x'}
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:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -4940,6 +4959,22 @@ packages:
hasBin: 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:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}

View File

@@ -0,0 +1,417 @@
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);
}

View File

@@ -0,0 +1,133 @@
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.',
);
});
});

View File

@@ -0,0 +1,22 @@
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);
});