feat: add isolated E2E browser test pipeline for Forgejo Actions
Some checks failed
CI / Lint, type check, unit tests, coverage (push) Successful in 1m53s
CI / E2E browser tests (push) Failing after 11s

Implement per-job Docker-in-Docker (DinD) for E2E tests, giving each
job a completely isolated Docker daemon and network. This prevents
leakage to the host Docker or other containers.

The previous E2E approach failed because:
1. The Forgejo runner's container.docker_host was not set, causing
   the runner itself to try unix:///var/run/docker.sock and crash-loop.
2. The host DinD daemon had isolated networking — job containers
   running docker compose could not resolve 'dind' hostname or access
   host filesystem bind mounts (e.g. .:/app).

New approach — zero bind mounts, all COPY-based images:

- docker/backend.e2e.Dockerfile: multi-stage build from repo root.
  Copies gradlew + settings.gradle + backend/build.gradle to download
  dependencies in a cacheable layer, then copies backend/src and builds
  the bootJar. Runs the JAR directly on startup.

- docker/frontend.e2e.Dockerfile: multi-stage Node build → nginx.
  Reuses existing docker/nginx.conf for /api proxy to backend service.
  No volume mounts, fully self-contained.

- docker/playwright.e2e.Dockerfile: extends official Playwright image.
  Installs deps from package-lock.json, copies e2e tests + config.

- docker-compose.e2e.yml: zero bind mounts. Services depend on each
  other in order: postgres (healthy) → backend → frontend → playwright.
  Playwright waits for backend and frontend via curl loops before
  running tests.

- .forgejo/workflows/ci.yml: E2E job adds a 'dind' service container
  (docker:28-dind, privileged, no TLS). The job sets DOCKER_HOST to
  tcp://dind:2375 so the docker CLI inside the job talks to the
  per-job DinD daemon. The compose file is docker-compose.e2e.yml.

- Runner fix on tocke: added container.docker_host: 'tcp://dind:2375'
  to runner-config.yaml so the runner's own Docker client connects to
  the host DinD container, stopping the crash loop.

Key properties:
- Network isolation: each E2E job gets its own DinD with its own
  container network. No host container visibility.
- No bind mount leakage: all images use COPY instead of volume mounts.
  The per-job DinD has its own filesystem and can't see host paths.
- Deterministic: builds start from clean state every time. Image cache
  exists only within the per-job DinD lifetime.
- Lint-and-test job is untouched and remains green.
This commit is contained in:
Joakim Mörling 2026-05-19 18:07:12 +02:00
parent 8e3632f05f
commit 1f1016a775
5 changed files with 101 additions and 1 deletions

View file

@ -58,7 +58,14 @@ jobs:
e2e: e2e:
name: E2E browser tests name: E2E browser tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
dind:
image: docker:28-dind
options: --privileged
env:
DOCKER_TLS_CERTDIR: ""
env: env:
DOCKER_HOST: tcp://dind:2375
POSTGRES_DB: bilhej POSTGRES_DB: bilhej
POSTGRES_USER: bilhej POSTGRES_USER: bilhej
POSTGRES_PASSWORD: test_pw_ci_123 POSTGRES_PASSWORD: test_pw_ci_123
@ -77,5 +84,5 @@ jobs:
- name: Run E2E test stack - name: Run E2E test stack
run: | run: |
docker compose \ docker compose \
-f docker-compose.ci.yml \ -f docker-compose.e2e.yml \
up --build --abort-on-container-exit --exit-code-from playwright up --build --abort-on-container-exit --exit-code-from playwright

64
docker-compose.e2e.yml Normal file
View file

@ -0,0 +1,64 @@
services:
postgres:
image: postgres:16
container_name: bilhej-postgres-e2e
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
backend:
build:
dockerfile: docker/backend.e2e.Dockerfile
context: .
container_name: bilhej-backend-e2e
environment:
SPRING_PROFILES_ACTIVE: docker
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}
depends_on:
postgres:
condition: service_healthy
frontend:
build:
dockerfile: docker/frontend.e2e.Dockerfile
context: .
container_name: bilhej-frontend-e2e
depends_on:
- backend
playwright:
build:
dockerfile: docker/playwright.e2e.Dockerfile
context: .
container_name: bilhej-playwright-e2e
ipc: host
environment:
PLAYWRIGHT_BASE_URL: http://frontend
depends_on:
- frontend
command: >-
sh -c "
echo 'Waiting for backend...';
for i in \$(seq 1 60); do
curl -s http://backend:8080/api/vehicles/ZZZ999 > /dev/null && break;
sleep 1;
done;
echo 'Waiting for frontend...';
for i in \$(seq 1 30); do
curl -s http://frontend > /dev/null && break;
sleep 1;
done;
npx playwright test --reporter=list
"

View file

@ -0,0 +1,10 @@
FROM eclipse-temurin:21-jdk
WORKDIR /app
COPY gradlew settings.gradle ./
COPY gradle/wrapper/ gradle/wrapper/
COPY backend/build.gradle backend/
RUN chmod +x gradlew && ./gradlew :backend:dependencies --no-daemon -q
COPY backend/src backend/src
RUN ./gradlew :backend:bootJar --no-daemon -q
EXPOSE 8080
CMD ["sh", "-c", "java -jar backend/build/libs/*-SNAPSHOT.jar"]

View file

@ -0,0 +1,12 @@
FROM node:24-alpine AS builder
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View file

@ -0,0 +1,7 @@
FROM mcr.microsoft.com/playwright:v1.60.0-noble
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/playwright.config.ts ./
COPY frontend/e2e ./e2e
CMD ["sh", "-c", "npx playwright test --reporter=list"]