PR: fix/e2e-compose-dind → main
Last updated: 2026-02-23
E2E tests run the full stack (postgres, backend, reverse-proxy, frontend) via Docker Compose and exercise the app with Playwright against Firefox. Integration tests run vitest inside the backend container against a live database.
- E2E tests live in
packages/send/e2e/, tagged@dev-desktop. Entry point:scripts/e2e.sh(invoked bylerna run test:e2e:ci --scope=send-suite-e2e). - Integration tests run via
pnpm run test:integration:ci→scripts/integration.sh(invoked bylerna run test:integration:ci).
The default compose.yml mounts named Docker volumes over the node_modules directories inside each container:
volumes:
- ./:/app
- frontend-node-modules:/app/node_modulesThis is useful for local development but breaks in Docker-in-Docker environments (devpod, GitHub Actions with DinD). Named volumes initialise empty instead of copying from the image layer. The empty volume shadows the node_modules installed during docker build, leaving packages like @sentry/vite-plugin missing at runtime:
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@sentry/vite-plugin'
compose.ci.yml removes all named node_modules volumes and source bind mounts. Containers run directly from their built image layers.
Also, env_file: is used for the backend instead of a volumes: bind mount for .env, avoiding DinD path-resolution failures that leave bind-mounted files empty.
The container: job runs inside ghcr.io/.../cached-devcontainer:latest. Several non-obvious issues arise:
GitHub Actions sets HOME=/github/home (owned by uid 1001) even though the devcontainer container runs as root. Firefox refuses this combination.
scripts/e2e.sh works around this by:
- Pinning
PLAYWRIGHT_BROWSERS_PATHto the original$HOME/.cache/ms-playwrightat startup (before any HOME change) - Setting
HOME=/rootimmediately before launching Playwright
The devcontainer's seccomp profile blocks clone(CLONE_NEWUSER) and similar syscalls Firefox needs for its sandbox. Fixed by adding --security-opt seccomp=unconfined to the container options: in the workflow YAML.
Docker's default /dev/shm (64 MB) is too small for Firefox. Fixed by --shm-size=2gb in container options.
Two different DinD setups require different host addresses for published ports:
| Environment | Docker setup | Published ports accessible via |
|---|---|---|
GitHub Actions container: |
Host socket bind-mounted | Bridge gateway IP (ip route show default | awk '{print $3; exit}') |
| Devpod | Internal docker daemon | localhost |
scripts/e2e.sh and scripts/integration.sh detect this via GITHUB_ACTIONS=true.
The nginx reverse-proxy TLS cert (tls-dev-proxy/certs/localhost.crt) is issued for CN=localhost and SAN=DNS:localhost only. It is also expired (Dec 4, 2024).
In GitHub Actions CI the compose containers publish ports to the HOST's bridge IP (e.g. 172.18.0.1), not to localhost inside the CI container. The previous fix patched frontend/.env to point VITE_SEND_SERVER_URL at https://172.18.0.1:8088, but this caused Firefox to make TLS connections to 172.18.0.1:8088:
- Hostname mismatch: cert is for
localhost, not172.18.0.1 - Expired cert: Dec 2024
- Firefox hangs on SSL even with
ignoreHTTPSErrors: true(hostname mismatch bypasses that flag)
The hang prevents validateToken() from completing → router never redirects to /login → PublicLogin.vue never renders → register-button locator times out.
After servers are ready, scripts/e2e.sh starts a Node.js TCP proxy (in GitHub Actions CI only):
localhost:5173 → DOCKER_HOST:5173 (Vite dev server)
localhost:8088 → DOCKER_HOST:8088 (nginx/HTTPS reverse proxy)
Firefox connects to localhost:{5173,8088} — the cert CN matches, ignoreHTTPSErrors handles the expired/self-signed cert, and SSL succeeds. CORS also passes because the browser origin becomes http://localhost:5173, which is in SEND_BACKEND_CORS_ORIGINS.
node -e "
const net = require('net');
const host = process.argv[1];
[[5173, 5173], [8088, 8088]].forEach(([lp, rp]) => {
net.createServer(c => {
const r = net.connect(rp, host);
c.pipe(r); r.pipe(c);
c.on('error', () => r.destroy());
r.on('error', () => c.destroy());
}).listen(lp, '127.0.0.1', () =>
process.stdout.write('Proxy ready: localhost:' + lp + ' -> ' + host + ':' + rp + '\n'));
});" "${DOCKER_HOST}" &
TCP_PROXY_PID=$!The sed patches to frontend/.env were removed — the frontend keeps its default VITE_SEND_SERVER_URL=https://localhost:8088, which works via the proxy.
PLAYWRIGHT_BASE_URL is unconditionally set to http://localhost:5173.
The old scripts/integration.sh ran docker compose exec backend npm install vitest to install vitest inside the container. This triggered an ERESOLVE peer-dependency conflict because the project is managed by pnpm.
Fix: removed the npm install step. Vitest is already in devDependencies and is installed by pnpm install during the Docker build step, so npx vitest works without re-installing.
The Dockerfile ADD statements only copied src/, scripts/, public/, prisma/, package.json, and tsconfig.json. The integration config file at the repo root of packages/send/backend/ was not included, so vitest failed with:
✘ [ERROR] Could not resolve "/app/vitest.integration.config.js"
Fix: added to packages/send/backend/Dockerfile:
ADD vitest.integration.config.js ./The Run stack and test step previously had timeout-minutes: 10. The step must fit:
- Playwright browser install (
playwright install --with-deps) — ~1–2 min - Docker image build (
lerna run build-image:backend) — ~3–5 min - Server startup + health checks — ~1–2 min
- Test execution — ~3–7 min
Total often exceeds 10 minutes. Increased to timeout-minutes: 20 in e2e-test.yml, e2e-bucket-test.yml, and integration-test.yml.
package.json (backend) called pnpx eslint for lint:all. pnpx is not available in the CI container (only pnpm is). Changed to pnpm exec eslint.
| Workflow | Result | Notes |
|---|---|---|
| integration-tests | ✅ green | ~4 min |
| e2e-tests | "Login using OIDC" requires real TBPRO_USERNAME/TBPRO_PASSWORD secrets |
|
| e2e-bucket-tests | Same OIDC test fails | |
| validate-devcontainer | ✅ green | |
| validate | ❌ s3/backblaze unit tests | Requires real S3/B2 credentials (pre-existing) |
The one remaining e2e failure is credential-gated, not infrastructure. The OIDC login test (OIDC flow › Login using OIDC) attempts a real Thunderbird Pro OIDC flow that requires secrets not set in the public CI environment. All other tests (register, login, file upload/download/share/delete, key restore) pass.
| File | Change |
|---|---|
scripts/e2e.sh |
TCP proxy approach; remove sed patches; unconditional PLAYWRIGHT_BASE_URL=http://localhost:5173; HOME=/root fix; PLAYWRIGHT_BROWSERS_PATH pin |
scripts/integration.sh |
Remove npm install vitest; use DOCKER_HOST for health checks and compose exec |
packages/send/backend/Dockerfile |
Add vitest.integration.config.js |
packages/send/backend/package.json |
pnpx eslint → pnpm exec eslint |
.github/workflows/e2e-test.yml |
timeout-minutes: 10 → 20 |
.github/workflows/e2e-bucket-test.yml |
timeout-minutes: 10 → 20 |
.github/workflows/integration-test.yml |
timeout-minutes: 10 → 20 |
# From repo root — devpod has localhost access to containers
bash scripts/e2e.sh
# Or via lerna (as CI does):
IS_CI_AUTOMATION=yes lerna run test:e2e:ci --scope=send-suite-e2eThe devcontainer is only used with devpod for local testing. GitHub Actions CI uses ghcr.io/.../cached-devcontainer:latest as the container image directly (not the .devcontainer/ configuration).