fix: configure backend test environment #36
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| # Per-PR pipeline. Goal is twofold: | |
| # 1. Run every test layer (backend unit/API, frontend unit, E2E against a | |
| # real docker-compose stack) so a green PR is a real green PR. | |
| # 2. Treat the artifact bundle attached to each run AS the "preview env" | |
| # -- coverage HTML, Playwright report with screenshots, full compose | |
| # logs. Reviewers download them from the Checks page; no external | |
| # cloud deploy needed. | |
| on: | |
| push: | |
| branches: "**" | |
| pull_request: | |
| branches: [main] | |
| # Cancel superseded runs on the same ref so PR pushes don't queue forever. | |
| concurrency: | |
| group: ci-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| pull-requests: write # needed by the sticky-comment summary job | |
| jobs: | |
| # --------------------------------------------------------------------------- | |
| # Backend: lint + types + pytest with coverage | |
| # --------------------------------------------------------------------------- | |
| backend-unit: | |
| name: Backend (lint + tests + coverage) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| services: | |
| postgres: | |
| image: postgres:16-alpine | |
| env: | |
| POSTGRES_USER: lims | |
| POSTGRES_PASSWORD: lims | |
| POSTGRES_DB: lims_test | |
| ports: | |
| - 5432:5432 | |
| options: >- | |
| --health-cmd "pg_isready -U lims" | |
| --health-interval 5s | |
| --health-timeout 5s | |
| --health-retries 10 | |
| redis: | |
| image: redis:7-alpine | |
| ports: | |
| - 6379:6379 | |
| options: >- | |
| --health-cmd "redis-cli ping" | |
| --health-interval 5s | |
| --health-timeout 5s | |
| --health-retries 10 | |
| env: | |
| # conftest.py auto-creates lims_test, so we point at the maintenance DB | |
| # 'lims' here -- conftest connects to 'postgres' to issue CREATE DATABASE. | |
| DATABASE_URL: postgresql+asyncpg://lims:lims@localhost:5432/lims | |
| TEST_DATABASE_URL: postgresql+asyncpg://lims:lims@localhost:5432/lims_test | |
| REDIS_URL: redis://localhost:6379/0 | |
| JWT_SECRET: ci-test-secret-not-for-prod | |
| ENV: ci | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| - name: Set up Python 3.12 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" # 3.14 breaks SQLAlchemy 2.0.x (see backend/pyproject.toml) | |
| cache: "pip" | |
| cache-dependency-path: | | |
| backend/requirements.txt | |
| backend/requirements-dev.txt | |
| - name: Install dependencies | |
| working-directory: backend | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install -r requirements.txt -r requirements-dev.txt | |
| # JUnit XML + HTML coverage are nice-to-have artifacts; install only | |
| # if not already present, so requirements-dev.txt stays authoritative. | |
| pip install --quiet pytest-html || true | |
| - name: Lint (ruff) | |
| working-directory: backend | |
| run: ruff check . | |
| - name: Format check (ruff) | |
| working-directory: backend | |
| run: ruff format --check . | |
| - name: Type check (mypy) | |
| working-directory: backend | |
| run: mypy app | |
| - name: Verify alembic migration on the test DB | |
| working-directory: backend | |
| # Point alembic at the test DB so the migration is actually exercised | |
| # against the database the tests use. Without this override the step | |
| # ran against `lims` (which doesn't exist on the GHA postgres service, | |
| # since POSTGRES_DB above is `lims_test`) and silently no-op'd. | |
| # conftest still drops + recreates via Base.metadata before tests, so | |
| # this is purely a migration-drift guard. | |
| env: | |
| DATABASE_URL: postgresql+asyncpg://lims:lims@localhost:5432/lims_test | |
| run: alembic upgrade head | |
| - name: Tests (pytest + coverage) | |
| working-directory: backend | |
| # --cov-fail-under intentionally low -- this is a scaffold project. | |
| # Raise once the team has real coverage. | |
| run: | | |
| pytest \ | |
| --cov=app \ | |
| --cov-report=term-missing \ | |
| --cov-report=xml:coverage.xml \ | |
| --cov-report=html:htmlcov \ | |
| --junitxml=pytest-junit.xml | |
| - name: Upload backend coverage (HTML) | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: backend-coverage-html | |
| path: backend/htmlcov/ | |
| retention-days: 30 | |
| - name: Upload backend coverage (XML + JUnit) | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: backend-coverage-xml | |
| path: | | |
| backend/coverage.xml | |
| backend/pytest-junit.xml | |
| retention-days: 30 | |
| # --------------------------------------------------------------------------- | |
| # Frontend: lint + typecheck + vitest + Next.js build | |
| # --------------------------------------------------------------------------- | |
| frontend-unit: | |
| name: Frontend (lint + tests + build) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| - name: Set up Node.js 20 | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: "20.19.0" | |
| cache: "npm" | |
| cache-dependency-path: frontend/package-lock.json | |
| - name: Install dependencies | |
| working-directory: frontend | |
| run: npm ci | |
| - name: Format check (prettier) | |
| working-directory: frontend | |
| # Was previously enforced by .github/workflows/code_quality.yml; folded | |
| # into this workflow so ci.yml is the single source of truth and the | |
| # other workflow can be deleted. | |
| run: npm run format:check | |
| - name: Lint (eslint) | |
| working-directory: frontend | |
| # `npm run lint` runs eslint --max-warnings 0. Pipe to a file too so | |
| # we can attach it as an artifact even on failure. | |
| run: | | |
| npm run lint 2>&1 | tee eslint-report.txt | |
| - name: Type check (tsc) | |
| working-directory: frontend | |
| run: npm run typecheck | |
| - name: Build (next build) | |
| working-directory: frontend | |
| run: npm run build | |
| - name: Test (vitest with coverage) | |
| working-directory: frontend | |
| # vitest exits 0 if there are no tests (--passWithNoTests). Coverage | |
| # is best-effort: install @vitest/coverage-v8 ad-hoc if the team | |
| # hasn't added it yet, so the testing-engineer's tests pick it up | |
| # whenever they land. | |
| run: | | |
| if ! npm ls @vitest/coverage-v8 >/dev/null 2>&1; then | |
| npm install --no-save --no-audit --no-fund @vitest/coverage-v8 | |
| fi | |
| npx vitest run --coverage --passWithNoTests --reporter=default --reporter=junit --outputFile=vitest-junit.xml | |
| - name: Upload frontend coverage | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: frontend-coverage | |
| path: | | |
| frontend/coverage/ | |
| frontend/vitest-junit.xml | |
| retention-days: 30 | |
| if-no-files-found: ignore | |
| - name: Upload eslint report | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: frontend-eslint | |
| path: frontend/eslint-report.txt | |
| retention-days: 14 | |
| if-no-files-found: ignore | |
| - name: Upload Next.js build output | |
| if: success() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: frontend-next-build | |
| # Skip the chonky cache dir; ship only what proves the build worked | |
| # and what a reviewer would actually open. | |
| path: | | |
| frontend/.next/standalone/ | |
| frontend/.next/static/ | |
| frontend/.next/BUILD_ID | |
| frontend/.next/build-manifest.json | |
| retention-days: 14 | |
| if-no-files-found: warn | |
| # --------------------------------------------------------------------------- | |
| # Docker compose build smoke -- catches Dockerfile regressions before the | |
| # heavier integration-e2e job spins everything up. | |
| # --------------------------------------------------------------------------- | |
| docker-build: | |
| name: Docker compose build | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v3 | |
| - name: Build all services (with overlay) | |
| run: | | |
| docker compose \ | |
| -f docker-compose.yml \ | |
| -f docker-compose.ci.yml \ | |
| build | |
| # --------------------------------------------------------------------------- | |
| # The headline job: spin up the full stack via docker compose, migrate + | |
| # seed, then run Playwright E2E against it. Artifacts (Playwright HTML | |
| # report with screenshots/traces + full compose logs) are the "preview env" | |
| # reviewers download from the Checks page. | |
| # --------------------------------------------------------------------------- | |
| integration-e2e: | |
| name: Integration + E2E (compose stack) | |
| runs-on: ubuntu-latest | |
| needs: [docker-build] | |
| timeout-minutes: 30 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Set up Node.js 20 | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: "20" | |
| # We cache the e2e harness's node_modules separately; only enable | |
| # the built-in npm cache if a lockfile exists. | |
| cache: "npm" | |
| cache-dependency-path: tests/e2e/package-lock.json | |
| - name: Prepare backend env | |
| run: cp backend/.env.example backend/.env | |
| - name: Create lims_devnet (devcontainer-shared network) | |
| # postgres + redis attach to this external network so the | |
| # .devcontainer workspace can reach them by service name. CI | |
| # doesn't use the devcontainer but compose still requires the | |
| # network to exist before `up`. | |
| run: docker network inspect lims_devnet >/dev/null 2>&1 || docker network create lims_devnet | |
| - name: Boot the stack | |
| run: | | |
| docker compose \ | |
| -f docker-compose.yml \ | |
| -f docker-compose.ci.yml \ | |
| up -d --build \ | |
| postgres redis backend celery-worker celery-beat frontend | |
| - name: Wait for backend /health | |
| # Backend Dockerfile already declares HEALTHCHECK on /health; we | |
| # additionally poll from the runner so we fail fast with a clear | |
| # message instead of letting Playwright connect to a half-up app. | |
| run: | | |
| for i in $(seq 1 60); do | |
| if curl -fsS http://localhost:8000/health >/dev/null 2>&1; then | |
| echo "backend up after ${i}s" | |
| exit 0 | |
| fi | |
| sleep 2 | |
| done | |
| echo "backend never became healthy" | |
| docker compose -f docker-compose.yml -f docker-compose.ci.yml ps | |
| docker compose -f docker-compose.yml -f docker-compose.ci.yml logs --no-color backend | tail -200 | |
| exit 1 | |
| - name: Migrate + seed | |
| run: | | |
| docker compose -f docker-compose.yml -f docker-compose.ci.yml \ | |
| exec -T backend alembic upgrade head | |
| docker compose -f docker-compose.yml -f docker-compose.ci.yml \ | |
| exec -T backend python scripts/seed_dev.py | |
| - name: Wait for frontend | |
| run: | | |
| for i in $(seq 1 60); do | |
| # Next.js standalone returns 200 on / once compiled. A 404 from | |
| # the framework is also "up" (means server is responding), so | |
| # accept any HTTP response code below 500. | |
| code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 || echo "000") | |
| if [ "$code" != "000" ] && [ "$code" -lt 500 ]; then | |
| echo "frontend up after ${i}s (HTTP $code)" | |
| exit 0 | |
| fi | |
| sleep 2 | |
| done | |
| echo "frontend never became healthy" | |
| docker compose -f docker-compose.yml -f docker-compose.ci.yml logs --no-color frontend | tail -200 | |
| exit 1 | |
| - name: Install Playwright harness | |
| # If the testing-engineer agent hasn't landed tests/e2e yet, create | |
| # a stub so the workflow still passes and uploads a (empty) report. | |
| # Once tests/e2e/package.json lands, the `npm install` below installs | |
| # it for real. | |
| run: | | |
| if [ ! -d tests/e2e ]; then | |
| echo "tests/e2e missing -- creating stub so CI still produces artifacts" | |
| mkdir -p tests/e2e | |
| cat > tests/e2e/package.json <<'EOF' | |
| { | |
| "name": "lims-e2e", | |
| "private": true, | |
| "scripts": { "test": "playwright test" }, | |
| "devDependencies": { "@playwright/test": "^1.49.0" } | |
| } | |
| EOF | |
| cat > tests/e2e/playwright.config.ts <<'EOF' | |
| import { defineConfig } from "@playwright/test"; | |
| export default defineConfig({ | |
| testDir: "./", | |
| reporter: [["html", { open: "never" }], ["list"]], | |
| use: { baseURL: process.env.FRONTEND_URL ?? "http://localhost:3000", trace: "retain-on-failure", screenshot: "only-on-failure" }, | |
| }); | |
| EOF | |
| cat > tests/e2e/smoke.spec.ts <<'EOF' | |
| import { test, expect } from "@playwright/test"; | |
| test("frontend responds", async ({ page }) => { | |
| const res = await page.goto("/"); | |
| expect(res?.status() ?? 500).toBeLessThan(500); | |
| }); | |
| EOF | |
| fi | |
| cd tests/e2e && npm install --no-audit --no-fund | |
| - name: Cache Playwright browsers | |
| uses: actions/cache@v4 | |
| id: playwright-cache | |
| with: | |
| path: ~/.cache/ms-playwright | |
| key: playwright-${{ runner.os }}-${{ hashFiles('tests/e2e/package-lock.json', 'tests/e2e/package.json') }} | |
| - name: Install Playwright browsers | |
| if: steps.playwright-cache.outputs.cache-hit != 'true' | |
| working-directory: tests/e2e | |
| run: npx playwright install --with-deps chromium | |
| - name: Install Playwright system deps (cache hit path) | |
| if: steps.playwright-cache.outputs.cache-hit == 'true' | |
| working-directory: tests/e2e | |
| run: npx playwright install-deps chromium | |
| - name: Run Playwright | |
| working-directory: tests/e2e | |
| env: | |
| FRONTEND_URL: http://localhost:3000 | |
| BACKEND_URL: http://localhost:8000 | |
| run: npx playwright test | |
| - name: Capture compose logs | |
| if: always() | |
| run: | | |
| mkdir -p artifacts | |
| docker compose -f docker-compose.yml -f docker-compose.ci.yml \ | |
| logs --no-color --timestamps > artifacts/compose-logs.txt 2>&1 || true | |
| docker compose -f docker-compose.yml -f docker-compose.ci.yml \ | |
| ps > artifacts/compose-ps.txt 2>&1 || true | |
| - name: Upload Playwright report | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: playwright-report | |
| path: tests/e2e/playwright-report/ | |
| retention-days: 30 | |
| if-no-files-found: warn | |
| - name: Upload Playwright test-results (traces + screenshots) | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: playwright-test-results | |
| path: tests/e2e/test-results/ | |
| retention-days: 30 | |
| if-no-files-found: ignore | |
| - name: Upload compose logs | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: compose-logs | |
| path: artifacts/ | |
| retention-days: 30 | |
| if-no-files-found: warn | |
| - name: Tear down stack | |
| if: always() | |
| run: | | |
| docker compose -f docker-compose.yml -f docker-compose.ci.yml \ | |
| down -v --remove-orphans || true | |
| # --------------------------------------------------------------------------- | |
| # Summary: sticky PR comment with status of each layer + artifact links. | |
| # Non-blocking; if it fails (e.g. forked PR can't write comments) the rest | |
| # of the run is still green. | |
| # --------------------------------------------------------------------------- | |
| summary: | |
| name: PR summary | |
| runs-on: ubuntu-latest | |
| needs: [backend-unit, frontend-unit, docker-build, integration-e2e] | |
| if: always() && github.event_name == 'pull_request' | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| steps: | |
| - name: Compose summary body | |
| id: body | |
| env: | |
| BACKEND: ${{ needs.backend-unit.result }} | |
| FRONTEND: ${{ needs.frontend-unit.result }} | |
| DOCKER: ${{ needs.docker-build.result }} | |
| E2E: ${{ needs.integration-e2e.result }} | |
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| run: | | |
| icon() { | |
| case "$1" in | |
| success) echo ":white_check_mark:" ;; | |
| failure) echo ":x:" ;; | |
| cancelled) echo ":no_entry:" ;; | |
| skipped) echo ":fast_forward:" ;; | |
| *) echo ":grey_question:" ;; | |
| esac | |
| } | |
| { | |
| echo "body<<EOF" | |
| echo "### CI summary" | |
| echo "" | |
| echo "| Layer | Status |" | |
| echo "|---|---|" | |
| echo "| Backend (lint + tests + coverage) | $(icon "$BACKEND") $BACKEND |" | |
| echo "| Frontend (lint + tests + build) | $(icon "$FRONTEND") $FRONTEND |" | |
| echo "| Docker compose build | $(icon "$DOCKER") $DOCKER |" | |
| echo "| Integration + E2E (compose stack) | $(icon "$E2E") $E2E |" | |
| echo "" | |
| echo "Download artifacts (coverage HTML, Playwright report with screenshots, full compose logs) from the [run page]($RUN_URL)." | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Post sticky comment | |
| # Skip on forked PRs -- GITHUB_TOKEN there is read-only. | |
| if: github.event.pull_request.head.repo.full_name == github.repository | |
| uses: marocchino/sticky-pull-request-comment@v2 | |
| with: | |
| header: ci-summary | |
| message: ${{ steps.body.outputs.body }} |