name: E2E Tests on: pull_request: branches: [main, dev] types: [opened, synchronize, reopened, ready_for_review] paths: - 'surfsense_web/**' - 'surfsense_backend/**' - '.github/workflows/e2e-tests.yml' workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: e2e: name: Journey runs-on: ubuntu-latest if: github.event.pull_request.draft == false timeout-minutes: 45 # Postgres runs as a step (not a service) so we can pass `-c wal_level=logical`, # required for migration 117's zero-cache publications. services: redis: image: redis:8-alpine ports: - 6379:6379 options: >- --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 env: DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/surfsense_e2e CELERY_BROKER_URL: redis://localhost:6379/0 CELERY_RESULT_BACKEND: redis://localhost:6379/0 REDIS_APP_URL: redis://localhost:6379/0 SECRET_KEY: ci-test-secret-key-not-for-production AUTH_TYPE: LOCAL REGISTRATION_ENABLED: "TRUE" ETL_SERVICE: DOCLING EMBEDDING_MODEL: sentence-transformers/all-MiniLM-L6-v2 NEXT_FRONTEND_URL: http://localhost:3000 # Sentinel keys — fakes never read them; turns leaked real calls into 401s. COMPOSIO_API_KEY: e2e-deny-real-call-sentinel COMPOSIO_ENABLED: "TRUE" OPENAI_API_KEY: e2e-deny-real-call-sentinel ANTHROPIC_API_KEY: e2e-deny-real-call-sentinel LITELLM_API_KEY: e2e-deny-real-call-sentinel MICROSOFT_CLIENT_ID: fake-microsoft-client-id MICROSOFT_CLIENT_SECRET: fake-microsoft-client-secret ONEDRIVE_REDIRECT_URI: http://localhost:8000/api/v1/auth/onedrive/connector/callback DROPBOX_APP_KEY: fake-dropbox-app-key DROPBOX_APP_SECRET: fake-dropbox-app-secret DROPBOX_REDIRECT_URI: http://localhost:8000/api/v1/auth/dropbox/connector/callback NEXT_PUBLIC_FASTAPI_BACKEND_URL: http://localhost:8000 NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: LOCAL PLAYWRIGHT_TEST_EMAIL: e2e-test@surfsense.net PLAYWRIGHT_TEST_PASSWORD: E2eTestPassword123! steps: - name: Checkout code uses: actions/checkout@v6 # Started early so it warms up while Python deps install. - name: Start Postgres run: | docker run -d \ --name surfsense_postgres \ -p 5432:5432 \ -e POSTGRES_USER=postgres \ -e POSTGRES_PASSWORD=postgres \ -e POSTGRES_DB=surfsense_e2e \ pgvector/pgvector:pg17 \ postgres \ -c wal_level=logical \ -c max_wal_senders=10 \ -c max_replication_slots=10 - name: Set up Python uses: actions/setup-python@v6 with: python-version: '3.12' - name: Install uv uses: astral-sh/setup-uv@v8.1.0 - name: Cache backend dependencies uses: actions/cache@v5 with: path: | ~/.cache/uv surfsense_backend/.venv key: python-deps-${{ hashFiles('surfsense_backend/uv.lock') }} restore-keys: | python-deps- - name: Cache HuggingFace models uses: actions/cache@v5 with: path: ~/.cache/huggingface key: hf-models-${{ env.EMBEDDING_MODEL }}-${{ env.ETL_SERVICE }} - name: Install backend dependencies working-directory: surfsense_backend run: uv sync - name: Wait for Postgres readiness run: | for i in $(seq 1 30); do if docker exec surfsense_postgres pg_isready -U postgres -d surfsense_e2e > /dev/null 2>&1; then echo "Postgres ready after ${i} attempts" exit 0 fi sleep 2 done echo "::error::Postgres failed to become ready within 60s" docker logs surfsense_postgres --tail 100 exit 1 - name: Run database migrations working-directory: surfsense_backend run: uv run alembic upgrade head # Do NOT replace with `uvicorn main:app`. run_backend.py hijacks # sys.modules["composio"] before app import; production binds it # at import time so plain uvicorn would call the real SDK. - name: Start backend (E2E entrypoint with sys.modules hijack) working-directory: surfsense_backend env: HTTPS_PROXY: http://127.0.0.1:1 HTTP_PROXY: http://127.0.0.1:1 NO_PROXY: localhost,127.0.0.1,0.0.0.0,huggingface.co,*.huggingface.co,*.hf.co,cdn-lfs.huggingface.co run: | uv run python tests/e2e/run_backend.py \ > backend.log 2>&1 & echo $! > backend.pid # Worker is a separate interpreter, so the composio hijack must be reapplied. - name: Start Celery worker (E2E entrypoint) working-directory: surfsense_backend env: HTTPS_PROXY: http://127.0.0.1:1 HTTP_PROXY: http://127.0.0.1:1 NO_PROXY: localhost,127.0.0.1,0.0.0.0,huggingface.co,*.huggingface.co,*.hf.co,cdn-lfs.huggingface.co run: | uv run python tests/e2e/run_celery.py \ > celery.log 2>&1 & echo $! > celery.pid - name: Wait for backend readiness run: | for i in $(seq 1 60); do if curl -sf http://localhost:8000/openapi.json > /dev/null; then echo "Backend up after ${i} attempts" exit 0 fi sleep 2 done echo "::error::Backend failed to start within 120s" echo "===== backend.log (tail 200) =====" tail -200 surfsense_backend/backend.log || true echo "===== celery.log (tail 200) =====" tail -200 surfsense_backend/celery.log || true exit 1 - name: Wait for Celery worker readiness working-directory: surfsense_backend run: | for i in $(seq 1 30); do if uv run celery -A app.celery_app inspect ping --timeout 2 \ > /dev/null 2>&1; then echo "Celery worker up after ${i} attempts" exit 0 fi sleep 2 done echo "::error::Celery worker failed to start within 60s" echo "===== celery.log (tail 200) =====" tail -200 celery.log || true exit 1 - name: Register E2E test user run: | # 200/201 = created, 400 = already exists (idempotent across reruns). STATUS=$(curl -s -o /tmp/register.json -w "%{http_code}" \ -X POST http://localhost:8000/auth/register \ -H "Content-Type: application/json" \ -d "{\"email\":\"${PLAYWRIGHT_TEST_EMAIL}\",\"password\":\"${PLAYWRIGHT_TEST_PASSWORD}\"}") echo "Register status: ${STATUS}" cat /tmp/register.json if [ "${STATUS}" != "200" ] && [ "${STATUS}" != "201" ] && [ "${STATUS}" != "400" ]; then echo "::error::Failed to register test user (status ${STATUS})" exit 1 fi - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: '20' - name: Install pnpm uses: pnpm/action-setup@v6 with: version: 10 - name: Get pnpm store directory id: pnpm-cache shell: bash run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - name: Cache pnpm store uses: actions/cache@v5 with: path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} key: pnpm-${{ runner.os }}-${{ hashFiles('surfsense_web/pnpm-lock.yaml') }} restore-keys: | pnpm-${{ runner.os }}- - name: Install web dependencies working-directory: surfsense_web run: pnpm install --frozen-lockfile - name: Cache Playwright browsers id: playwright-cache uses: actions/cache@v5 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('surfsense_web/pnpm-lock.yaml') }} - name: Install Playwright browsers if: steps.playwright-cache.outputs.cache-hit != 'true' working-directory: surfsense_web run: pnpm exec playwright install --with-deps chromium - name: Install Playwright system deps (cache hit) if: steps.playwright-cache.outputs.cache-hit == 'true' working-directory: surfsense_web run: pnpm exec playwright install-deps chromium - name: Run Playwright tests working-directory: surfsense_web run: pnpm test:e2e - name: Upload Playwright HTML report if: always() uses: actions/upload-artifact@v7 with: name: playwright-report path: surfsense_web/playwright-report/ retention-days: 14 - name: Upload Playwright traces if: failure() uses: actions/upload-artifact@v7 with: name: playwright-traces path: surfsense_web/test-results/ retention-days: 14 - name: Upload backend + celery logs if: failure() uses: actions/upload-artifact@v7 with: name: backend-celery-logs path: | surfsense_backend/backend.log surfsense_backend/celery.log retention-days: 7 - name: Stop backend + Celery worker if: always() working-directory: surfsense_backend run: | for f in backend.pid celery.pid; do if [ -f "$f" ]; then kill "$(cat $f)" 2>/dev/null || true fi done - name: Stop Postgres if: always() run: docker rm -f surfsense_postgres 2>/dev/null || true