From e27e26b071fa2067fec289f07ce91ff6cffea02e Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:54:57 +0800 Subject: [PATCH] test: add frontend smoke and backend e2e CI --- .github/workflows/frontend-tests.yml | 46 +++ .github/workflows/lint.yml | 2 +- .github/workflows/run-tests.yml | 63 +++- tests/README.md | 62 +++- tests/e2e/conftest.py | 4 +- tests/e2e/test_startup.py | 6 +- tests/e2e/utils/process_manager.py | 17 +- web/.gitignore | 2 + web/README.md | 10 + web/package.json | 2 + web/playwright.config.ts | 25 ++ web/pnpm-lock.yaml | 35 +++ web/tests/e2e/fixtures/langbot-api.ts | 417 ++++++++++++++++++++++++++ web/tests/e2e/home-smoke.spec.ts | 133 ++++++++ web/tests/e2e/login.spec.ts | 22 ++ 15 files changed, 835 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/frontend-tests.yml create mode 100644 web/playwright.config.ts create mode 100644 web/tests/e2e/fixtures/langbot-api.ts create mode 100644 web/tests/e2e/home-smoke.spec.ts create mode 100644 web/tests/e2e/login.spec.ts diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml new file mode 100644 index 00000000..0265e044 --- /dev/null +++ b/.github/workflows/frontend-tests.yml @@ -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 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e1d89c1e..f2baae7c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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 diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index c3368401..aaee5954 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -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 \ No newline at end of file + echo "Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY diff --git a/tests/README.md b/tests/README.md index e490ed5c..3a110b9d 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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 \ No newline at end of file +- [ ] Add property-based testing with Hypothesis diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 200ac22a..ddef1abd 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -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' \ No newline at end of file + return e2e_tmpdir / 'data' / 'langbot.db' diff --git a/tests/e2e/test_startup.py b/tests/e2e/test_startup.py index dcbe8e75..8954505a 100644 --- a/tests/e2e/test_startup.py +++ b/tests/e2e/test_startup.py @@ -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)) diff --git a/tests/e2e/utils/process_manager.py b/tests/e2e/utils/process_manager.py index 888b5dec..84050987 100644 --- a/tests/e2e/utils/process_manager.py +++ b/tests/e2e/utils/process_manager.py @@ -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') \ No newline at end of file + return Path('/home/glwuy/langbot-app/LangBot-test-build') diff --git a/web/.gitignore b/web/.gitignore index d50a1813..1326feb7 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -12,6 +12,8 @@ # testing /coverage +/playwright-report +/test-results # next.js /dist/ diff --git a/web/README.md b/web/README.md index ae1de3cf..b8ed72c6 100644 --- a/web/README.md +++ b/web/README.md @@ -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. diff --git a/web/package.json b/web/package.json index 555f1030..59f17f40 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 00000000..e15c6ef9 --- /dev/null +++ b/web/playwright.config.ts @@ -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, + }, +}); diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index c001a765..25660ff9 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -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'} diff --git a/web/tests/e2e/fixtures/langbot-api.ts b/web/tests/e2e/fixtures/langbot-api.ts new file mode 100644 index 00000000..08f23a5b --- /dev/null +++ b/web/tests/e2e/fixtures/langbot-api.ts @@ -0,0 +1,417 @@ +import { Page, Route } from '@playwright/test'; + +type JsonRecord = Record; + +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); +} diff --git a/web/tests/e2e/home-smoke.spec.ts b/web/tests/e2e/home-smoke.spec.ts new file mode 100644 index 00000000..32623864 --- /dev/null +++ b/web/tests/e2e/home-smoke.spec.ts @@ -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.', + ); + }); +}); diff --git a/web/tests/e2e/login.spec.ts b/web/tests/e2e/login.spec.ts new file mode 100644 index 00000000..ae9735a6 --- /dev/null +++ b/web/tests/e2e/login.spec.ts @@ -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); +});