mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-09 23:36:02 +00:00
test(e2e): add minimal startup E2E tests
Add E2E tests for LangBot startup flow: - tests/e2e/utils/config_factory.py: minimal config generation - tests/e2e/utils/process_manager.py: LangBot subprocess management - tests/e2e/conftest.py: E2E fixtures (session-scoped process) - tests/e2e/test_startup.py: 12 tests for startup verification Tests verify: - boot.py + stages execution - database initialization (SQLite) - API availability - migrations applied Uses embedded databases (SQLite, Chroma) - no external dependencies. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
205
tests/e2e/utils/process_manager.py
Normal file
205
tests/e2e/utils/process_manager.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""E2E test process manager.
|
||||
|
||||
Manages LangBot subprocess lifecycle for E2E testing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
import signal
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LangBotProcess:
|
||||
"""Manages a LangBot subprocess for E2E testing."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_root: Path,
|
||||
work_dir: Path,
|
||||
port: int = 15300,
|
||||
timeout: int = 30,
|
||||
collect_coverage: bool = True,
|
||||
):
|
||||
self.project_root = project_root
|
||||
self.work_dir = work_dir # Directory containing data/config.yaml
|
||||
self.port = port
|
||||
self.timeout = timeout
|
||||
self.collect_coverage = collect_coverage
|
||||
self.process: Optional[subprocess.Popen] = None
|
||||
self._stdout_data: bytes = b''
|
||||
self._stderr_data: bytes = b''
|
||||
self._coverage_file: Optional[Path] = None
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start LangBot process and wait for it to be ready."""
|
||||
import httpx
|
||||
|
||||
# Prepare environment
|
||||
env = os.environ.copy()
|
||||
env['PYTHONPATH'] = str(self.project_root / 'src')
|
||||
|
||||
# Set API port via environment variable
|
||||
env['API__PORT'] = str(self.port)
|
||||
env['API__WEBHOOK_PREFIX'] = f'http://127.0.0.1:{self.port}'
|
||||
|
||||
# Disable telemetry
|
||||
env['SPACE__DISABLE_TELEMETRY'] = 'true'
|
||||
env['SPACE__DISABLE_MODELS_SERVICE'] = 'true'
|
||||
|
||||
# Build command
|
||||
if self.collect_coverage:
|
||||
# Use coverage.py to collect coverage data
|
||||
# Set COVERAGE_PROCESS_START to enable coverage in subprocess
|
||||
self._coverage_file = self.work_dir / '.coverage.e2e'
|
||||
env['COVERAGE_PROCESS_START'] = str(self.project_root / '.coveragerc')
|
||||
env['COVERAGE_FILE'] = str(self._coverage_file)
|
||||
|
||||
# Create .coveragerc for subprocess
|
||||
coveragerc_content = """
|
||||
[run]
|
||||
source = langbot.pkg
|
||||
parallel = True
|
||||
data_file = {}
|
||||
omit =
|
||||
*/tests/*
|
||||
*/test_*.py
|
||||
|
||||
[report]
|
||||
precision = 2
|
||||
""".format(str(self._coverage_file))
|
||||
coveragerc_path = self.work_dir / '.coveragerc'
|
||||
with open(coveragerc_path, 'w') as f:
|
||||
f.write(coveragerc_content)
|
||||
|
||||
cmd = [
|
||||
'coverage', 'run',
|
||||
'--rcfile=' + str(coveragerc_path),
|
||||
'-m', 'langbot',
|
||||
]
|
||||
else:
|
||||
cmd = ['uv', 'run', 'python', '-m', 'langbot']
|
||||
|
||||
logger.info(f'Starting LangBot in: {self.work_dir}')
|
||||
logger.info(f'Command: {cmd}')
|
||||
|
||||
# Start process (run in work_dir so it finds data/config.yaml)
|
||||
self.process = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=self.work_dir,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
preexec_fn=os.setsid if os.name != 'nt' else None,
|
||||
)
|
||||
|
||||
# Wait for startup
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < self.timeout:
|
||||
# Check if process died
|
||||
if self.process.poll() is not None:
|
||||
self._stdout_data, self._stderr_data = self.process.communicate()
|
||||
logger.error(f'LangBot process died: {self._stderr_data.decode()}')
|
||||
return False
|
||||
|
||||
# Try to connect
|
||||
try:
|
||||
r = httpx.get(
|
||||
f'http://127.0.0.1:{self.port}/api/v1/system/info',
|
||||
timeout=2.0,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
logger.info(f'LangBot started successfully on port {self.port}')
|
||||
return True
|
||||
except (httpx.ConnectError, httpx.TimeoutException):
|
||||
pass
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
# Timeout
|
||||
logger.error(f'LangBot startup timeout after {self.timeout}s')
|
||||
self.stop()
|
||||
return False
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop LangBot process gracefully."""
|
||||
if self.process is None:
|
||||
return
|
||||
|
||||
logger.info('Stopping LangBot process...')
|
||||
|
||||
# Try graceful shutdown first
|
||||
if os.name != 'nt':
|
||||
# Send SIGTERM to process group
|
||||
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
|
||||
else:
|
||||
self.process.terminate()
|
||||
|
||||
# Wait for graceful shutdown
|
||||
try:
|
||||
self.process.wait(timeout=5)
|
||||
logger.info('LangBot stopped gracefully')
|
||||
except subprocess.TimeoutExpired:
|
||||
# Force kill
|
||||
logger.warning('Force killing LangBot process')
|
||||
if os.name != 'nt':
|
||||
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
|
||||
else:
|
||||
self.process.kill()
|
||||
self.process.wait()
|
||||
|
||||
# Collect output for debugging
|
||||
if self.process.stdout or self.process.stderr:
|
||||
self._stdout_data, self._stderr_data = self.process.communicate()
|
||||
|
||||
self.process = None
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Check if process is still running."""
|
||||
return self.process is not None and self.process.poll() is None
|
||||
|
||||
def get_logs(self) -> tuple[str, str]:
|
||||
"""Get stdout and stderr logs."""
|
||||
stdout = self._stdout_data.decode('utf-8', errors='replace')
|
||||
stderr = self._stderr_data.decode('utf-8', errors='replace')
|
||||
return stdout, stderr
|
||||
|
||||
def get_coverage_file(self) -> Optional[Path]:
|
||||
"""Get coverage data file path."""
|
||||
return self._coverage_file
|
||||
|
||||
def health_check(self) -> bool:
|
||||
"""Check if LangBot API is responding."""
|
||||
import httpx
|
||||
|
||||
if not self.is_running():
|
||||
return False
|
||||
|
||||
try:
|
||||
r = httpx.get(
|
||||
f'http://127.0.0.1:{self.port}/api/v1/system/info',
|
||||
timeout=5.0,
|
||||
)
|
||||
return r.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def find_project_root() -> Path:
|
||||
"""Find LangBot project root directory."""
|
||||
current = Path(__file__).resolve()
|
||||
|
||||
# Walk up until we find src/langbot
|
||||
for parent in current.parents:
|
||||
if (parent / 'src' / 'langbot').exists():
|
||||
return parent
|
||||
|
||||
# Fallback to LangBot-test-build directory
|
||||
return Path('/home/glwuy/langbot-app/LangBot-test-build')
|
||||
Reference in New Issue
Block a user