🔧(e2e) setup e2e environment

Create a new project e2e based on playwright and setup a full dockerized e2e
environment
This commit is contained in:
jbpenrath
2025-11-14 10:02:02 +01:00
parent 85a8585323
commit 369bf83e05
28 changed files with 1014 additions and 130 deletions

View File

@@ -34,3 +34,5 @@ db.sqlite3
# Frontend
node_modules
out
.next

1
.gitignore vendored
View File

@@ -41,6 +41,7 @@ env.bak/
venv.bak/
env.d/development/*
!env.d/development/*.defaults
!env.d/development/*.e2e
env.d/terraform
# npm

View File

@@ -26,6 +26,7 @@
BOLD := \033[1m
RESET := \033[0m
GREEN := \033[1;32m
BLUE := \033[1;34m
# -- Docker
# Get the current user ID to use for docker run and docker exec commands
@@ -33,6 +34,7 @@ DOCKER_UID = $(shell id -u)
DOCKER_GID = $(shell id -g)
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE_E2E = DOCKER_USER=$(DOCKER_USER) docker compose -f src/e2e/compose.yaml
COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) backend-dev
COMPOSE_RUN = $(COMPOSE) run --rm --build
@@ -264,6 +266,93 @@ socks-proxy-test: ## run the socks-proxy tests
@$(COMPOSE) run --build --rm socks-proxy-test
.PHONY: socks-proxy-test
# -- E2E Tests
e2e-test: ## Setup, run and teardown e2e tests in headless mode
@$(MAKE) e2e-setup
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
$(MAKE) e2e-run-test args="$${args:-${1}}" || echo "$(BOLD)Tests failed$(RESET)"
@$(MAKE) e2e-teardown
.PHONY: e2e-test
e2e-test-ui: ## Setup, run and teardown e2e tests in UI mode
@$(MAKE) e2e-setup
@$(MAKE) e2e-run-test-ui
@$(MAKE) e2e-teardown
.PHONY: e2e-test-ui
e2e-test-dev: ## Setup, run and teardown e2e tests in UI mode with dev frontend
@$(MAKE) e2e-setup
@$(MAKE) e2e-run-test-dev
@$(MAKE) e2e-teardown
.PHONY: e2e-test-dev
e2e-test-ci: ## Setup and run e2e tests in CI mode
@$(MAKE) e2e-setup
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
$(MAKE) e2e-run-test -- $${args:-${1}}
.PHONY: e2e-test-ci
e2e-build: ## Build the e2e services
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
$(COMPOSE_E2E) build --no-cache $${args:-${1}}
.PHONY: e2e-build
e2e-log:
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
$(MAKE) e2e-logs -- $${args:-${1}}
.PHONY: e2e-log
e2e-logs: ## Show logs from e2e services
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
$(COMPOSE_E2E) --profile dev logs $${args:-${1}}
.PHONY: e2e-logs
e2e-run-test: ## Run e2e tests in headless mode
@echo "$(BLUE)\n\n| 🎭 Running E2E tests... \n$(RESET)"
$(COMPOSE_E2E) run --rm --service-ports runner npm run test -- $(args)
@echo "$(GREEN)> 🎭 E2E tests completed!$(RESET)\n"
.PHONY: e2e-run-test
e2e-run-test-ui: ## Run e2e tests in UI mode
@echo "$(BLUE)\n\n| 🎭 Running E2E tests in UI mode... \n$(RESET)"
# Note: || true allows graceful exit when user closes the UI
@$(COMPOSE_E2E) run --rm --service-ports runner npm run test:ui || true
@echo "$(GREEN)> 🎭 You killed the UI!$(RESET)\n"
.PHONY: e2e-run-test-ui
e2e-run-test-dev: ## Run e2e tests in UI mode with dev frontend
@echo "$(BLUE)\n\n| 🎭 Running E2E tests in dev mode... \n$(RESET)"
# Note: || true allows graceful exit when user closes the UI
E2E_PROFILE=dev $(COMPOSE_E2E) --profile dev run --rm --service-ports runner npm run test:ui || true
@echo "$(GREEN)> 🎭 You killed the UI!$(RESET)\n"
.PHONY: e2e-run-test-dev
e2e-down: ## Stop and remove all e2e services
@echo "$(BOLD)Stopping E2E services...$(RESET)"
@$(COMPOSE_E2E) --profile dev down -v
@echo "$(GREEN)✓ E2E services stopped$(RESET)"
.PHONY: e2e-down
e2e-demo: ## Populate the e2e database with demo data
@echo "$(BLUE)\n\n| 📝 Bootstrapping E2E demo data... \n$(RESET)"
@$(COMPOSE_E2E) run --rm backend python manage.py e2e_demo
.PHONY: e2e-demo
e2e-setup: ## Setup e2e services
@echo "$(BLUE)\n\n| 🔧 Setting up E2E services... \n$(RESET)"
@$(COMPOSE_E2E) run --rm objectstorage-createbucket
@$(COMPOSE_E2E) run --rm backend python manage.py migrate --noinput
@$(COMPOSE_E2E) run --rm backend python manage.py search_index_create || true
@$(MAKE) e2e-demo
.PHONY: e2e-setup
e2e-teardown: ## Teardown e2e services
@echo "$(BLUE)\n\n| 🧹 Cleaning up E2E services... \n$(RESET)"
@$(COMPOSE_E2E) --profile dev down -v
.PHONY: e2e-teardown
# -- Backend
migrations: ## run django makemigrations for the messages project.

View File

@@ -75,28 +75,22 @@ services:
depends_on:
objectstorage:
condition: service_healthy
environment:
- MC_HOST=http://objectstorage:9000
entrypoint: >
sh -c "
/usr/bin/mc alias set st-messages http://objectstorage:9000 st-messages password && \
/usr/bin/mc alias set st-messages $${MC_HOST} st-messages password && \
/usr/bin/mc mb st-messages/msg-imports --ignore-existing && \
/usr/bin/mc ilm rule rm --all --force st-messages/msg-imports || true && \
/usr/bin/mc ilm rule add --expire-days 1 st-messages/msg-imports"
backend-dev:
backend-base:
build:
context: src/backend
target: runtime-dev
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
environment:
- PYLINTHOME=/app/.pylint.d
- DJANGO_CONFIGURATION=Development
env_file:
- env.d/development/backend.defaults
- env.d/development/backend.local
ports:
- "8901:8000"
volumes:
- ./src/backend:/app
- ./data/static:/data/static
@@ -105,6 +99,17 @@ services:
interval: 3s
retries: 3
start_period: 10s
backend-dev:
extends: backend-base
environment:
- PYLINTHOME=/app/.pylint.d
- DJANGO_CONFIGURATION=Development
env_file:
- env.d/development/backend.defaults
- env.d/development/backend.local
ports:
- "8901:8000"
depends_on:
postgresql:
condition: service_healthy
@@ -121,14 +126,9 @@ services:
condition: service_started
backend-db:
extends: backend-base
profiles:
- tools
build:
context: src/backend
target: runtime-dev
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
environment:
- DJANGO_CONFIGURATION=DevelopmentMinimal
env_file:
@@ -136,18 +136,6 @@ services:
- env.d/development/backend.local
ports:
- "8901:8000"
volumes:
- ./src/backend:/app
- ./data/static:/data/static
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request as u; u.urlopen('http://localhost:8000/healthz/', timeout=1)"]
interval: 3s
retries: 3
start_period: 10s
depends_on:
postgresql:
condition: service_healthy
restart: true
backend-poetry:
profiles:
@@ -199,23 +187,17 @@ services:
- "8903:8803"
command: celery -A messages.celery_app flower --port=8803
# nginx:
# image: nginx:1.25
# ports:
# - "8083:8083"
# volumes:
# - ./docker/files/development/etc/nginx/conf.d:/etc/nginx/conf.d:ro
# depends_on:
# - keycloak
# - backend-dev
# - mta-in
# - mta-out
frontend-dev:
frontend-base:
user: "${DOCKER_USER:-1000}"
build:
context: ./src/frontend
dockerfile: Dockerfile
target: frontend-deps
args:
DOCKER_USER: ${DOCKER_USER:-1000}
frontend-dev:
extends: frontend-base
env_file:
- env.d/development/frontend.defaults
- env.d/development/frontend.local
@@ -226,27 +208,16 @@ services:
- "8900:3000"
frontend-tools:
user: "${DOCKER_USER:-1000}"
extends: frontend-base
profiles:
- frontend-tools
build:
context: ./src/frontend
dockerfile: Dockerfile
volumes:
- ./src/backend/core/api/openapi.json:/home/backend/core/api/openapi.json
- ./src/frontend/:/home/frontend/
frontend-tools-amd64:
user: "${DOCKER_USER:-1000}"
profiles:
- frontend-tools
extends: frontend-tools
platform: linux/amd64
build:
context: ./src/frontend
dockerfile: Dockerfile
volumes:
- ./src/backend/core/api/openapi.json:/home/backend/core/api/openapi.json
- ./src/frontend/:/home/frontend/
crowdin:
image: crowdin/cli:4.11.0
@@ -366,13 +337,16 @@ services:
volumes:
- ./src/keycloak/realm.json:/opt/keycloak/data/import/realm.json:ro
- ./src/keycloak/themes/dsfr-2.2.1.jar:/opt/keycloak/providers/keycloak-theme.jar:ro
environment:
- HOST=http://localhost:8902
- ADMIN_HOST=http://localhost:8902
command:
- start-dev
- --features=preview
- --import-realm
- --proxy=edge
- --hostname=http://localhost:8902
- --hostname-admin=http://localhost:8902/
- --hostname=$${HOST}
- --hostname-admin=$${ADMIN_HOST}
- --http-port=8802
env_file:
- env.d/development/keycloak.defaults

View File

@@ -229,7 +229,6 @@ The application uses a new environment file structure with `.defaults` and `.loc
|----------|---------|-------------|----------|
| `FRONTEND_THEME` | `dsfr` | Frontend theme identifier | Optional |
| `NEXT_PUBLIC_API_ORIGIN` | `http://localhost:8901` | Frontend API origin | Dev |
| `NEXT_PUBLIC_S3_DOMAIN_REPLACE` | `http://localhost:9000` | S3 domain replacement for frontend | Dev |
| `NEXT_PUBLIC_LANGUAGES` | `[["en-US","English"],["fr-FR","Français"],["nl-NL","Nederlands"]]` | Languages available for frontend | Optional |
| `NEXT_PUBLIC_DEFAULT_LANGUAGE` | `en-US` | Default language for frontend | Optional |

View File

@@ -0,0 +1,34 @@
# App database configuration
DB_HOST=postgresql
# Redis configuration
REDIS_URL=redis://redis:6379
CELERY_BROKER_URL=redis://redis:6379
# OpenSearch configuration
OPENSEARCH_URL=http://opensearch:9200
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://keycloak:8802/realms/messages/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT=http://keycloak:8802/realms/messages/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=http://keycloak:8802/realms/messages/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=http://keycloak:8802/realms/messages/protocol/openid-connect/userinfo
OIDC_REDIRECT_ALLOWED_HOSTS=["http://keycloak:8802", "http://nginx"]
LOGIN_REDIRECT_URL=http://nginx
LOGIN_REDIRECT_URL_FAILURE=http://nginx
LOGOUT_REDIRECT_URL=http://nginx
# Keycloak
KEYCLOAK_URL=http://keycloak:8802
# Object storage configuration
STORAGE_MESSAGE_IMPORTS_ENDPOINT_URL=http://objectstorage:9000
AWS_S3_DOMAIN_REPLACE=
# Email configuration (use mailcatcher from main services or disable)
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
# Debug
DJANGO_DEBUG=True

View File

@@ -1,5 +1,4 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8901
NEXT_PUBLIC_S3_DOMAIN_REPLACE=http://localhost:9000
NEXT_TELEMETRY_DISABLED=1
NEXT_PUBLIC_FEEDBACK_WIDGET_API_URL=http://localhost:8901/api/v1.0/inbound/widget/
NEXT_PUBLIC_FEEDBACK_WIDGET_PATH=http://localhost:8905/dist/

View File

@@ -0,0 +1,4 @@
NEXT_PUBLIC_API_ORIGIN=http://nginx
NEXT_PUBLIC_FEEDBACK_WIDGET_API_URL=
NEXT_PUBLIC_FEEDBACK_WIDGET_PATH=
NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL=

View File

@@ -0,0 +1 @@
KC_DB_URL_HOST=postgresql

View File

@@ -0,0 +1 @@
MDA_API_BASE_URL=http://backend:8000/api/v1.0/

View File

@@ -0,0 +1,228 @@
"""
Django management command to bootstrap E2E demo data.
This command creates demo users, mailboxes, and shared mailboxes for E2E testing
across different BROWSERS (chromium, firefox, webkit).
"""
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db import transaction
from core import models
from core.enums import MailboxRoleChoices, MailDomainAccessRoleChoices
from core.services.identity.keycloak import get_keycloak_admin_client
BROWSERS = ["chromium", "firefox", "webkit"]
DOMAIN_NAME = "example.local"
SHARED_MAILBOX_LOCAL_PART = "shared.e2e"
class Command(BaseCommand):
"""Create data for E2E demo data for testing."""
help = "Create data for E2E demo (users and mailboxes)"
@transaction.atomic
def handle(self, *args, **options):
"""Execute the command."""
if not settings.ENVIRONMENT == "e2e":
self.stdout.write(self.style.WARNING("Not in E2E environment"))
return
self.stdout.write(self.style.WARNING("\n\n| Creating E2E Demo Data\n"))
# Step 1: Get or create the domain
self.stdout.write(f"\n-- 1/4 📦 Setting up domain: {DOMAIN_NAME}")
domain, domain_created = models.MailDomain.objects.get_or_create(
name=DOMAIN_NAME,
defaults={
"oidc_autojoin": True,
"identity_sync": True,
},
)
if domain_created:
self.stdout.write(self.style.SUCCESS(f" ✓ Created domain: {DOMAIN_NAME}"))
else:
self.stdout.write(
self.style.SUCCESS(f" ✓ Domain already exists: {DOMAIN_NAME}")
)
# Step 2: Create users per browser
self.stdout.write(
f"\n-- 2/4 👥 Creating users for BROWSERS: {', '.join(BROWSERS)}"
)
regular_users = []
mailbox_admin_users = []
for browser in BROWSERS:
self.stdout.write(f"\n---- Browser: {browser}")
# Create superuser
superuser_email = f"super_admin.e2e.{browser}@{DOMAIN_NAME}"
self._create_user_with_mailbox(superuser_email, domain, is_superuser=True)
# Create domain admin user and mailbox
domain_admin_email = f"domain_admin.e2e.{browser}@{DOMAIN_NAME}"
domain_admin_user, domain_admin_mailbox = self._create_user_with_mailbox(
domain_admin_email, domain, is_domain_admin=True
)
# Create regular user and mailbox
regular_email = f"user.e2e.{browser}@{DOMAIN_NAME}"
regular_user, regular_mailbox = self._create_user_with_mailbox(
regular_email, domain
)
regular_users.append((regular_user, regular_mailbox))
# Create mailbox admin user and mailbox
mailbox_admin_email = f"mailbox_admin.e2e.{browser}@{DOMAIN_NAME}"
mailbox_admin_user, mailbox_admin_mailbox = self._create_user_with_mailbox(
mailbox_admin_email, domain
)
mailbox_admin_users.append((mailbox_admin_user, mailbox_admin_mailbox))
self.stdout.write(
self.style.SUCCESS(f" ✓ Mailbox admin: {mailbox_admin_email}")
)
# Step 3: Create shared mailbox
self.stdout.write(
f"\n-- 3/4 📥 Creating shared mailbox: {SHARED_MAILBOX_LOCAL_PART}@{DOMAIN_NAME}"
)
shared_mailbox = self._create_shared_mailbox(SHARED_MAILBOX_LOCAL_PART, domain)
self.stdout.write(
self.style.SUCCESS(
f" ✓ Shared mailbox created: {SHARED_MAILBOX_LOCAL_PART}@{DOMAIN_NAME}"
)
)
# Step 4: Add all regular users with sender role to the shared mailbox
self.stdout.write(
"\n-- 4/4 🔐 Adding users to shared mailbox with appropriate roles"
)
for user, _ in regular_users:
self._add_mailbox_access(shared_mailbox, user, MailboxRoleChoices.SENDER)
self.stdout.write(
self.style.SUCCESS(
f" ✓ Added {user.email} as SENDER to shared mailbox"
)
)
# Step 5: Add mailbox admin users with admin role to the shared mailbox
for user, _ in mailbox_admin_users:
self._add_mailbox_access(shared_mailbox, user, MailboxRoleChoices.ADMIN)
self.stdout.write(
self.style.SUCCESS(f" ✓ Added {user.email} as ADMIN to shared mailbox")
)
def _create_user_with_mailbox(
self, email, domain, is_domain_admin=False, is_superuser=False
):
"""Create a user with a personal mailbox."""
local_part = email.split("@")[0]
full_name = local_part.replace(".", " ").replace("-", " ").title()
# Create or get user
user, user_created = models.User.objects.get_or_create(
email=email,
defaults={
"is_superuser": is_superuser,
"full_name": full_name,
"password": "!",
},
)
keycloak_admin = get_keycloak_admin_client()
user_id = None
# Create or get mailbox
mailbox, mailbox_created = models.Mailbox.objects.get_or_create(
local_part=local_part,
domain=domain,
defaults={
"is_identity": True,
},
)
# Create or get contact
contact, _ = models.Contact.objects.get_or_create(
email=email,
mailbox=mailbox,
defaults={"name": full_name},
)
if not mailbox.contact:
mailbox.contact = contact
mailbox.save()
# Give the user admin access to their own mailbox
models.MailboxAccess.objects.get_or_create(
mailbox=mailbox,
user=user,
defaults={"role": MailboxRoleChoices.ADMIN},
)
# If this is a domain admin, grant domain access
if is_domain_admin:
models.MailDomainAccess.objects.get_or_create(
maildomain=domain,
user=user,
defaults={"role": MailDomainAccessRoleChoices.ADMIN},
)
# Set password for user in OIDC
users = get_keycloak_admin_client().get_users({"email": str(mailbox)})
if len(users) > 0:
user_id = users[0].get("id")
keycloak_admin.set_user_password(
user_id=user_id,
password="e2e", # noqa: S106
temporary=False,
)
self.stdout.write(
self.style.SUCCESS(f"✓ Password set for user {user.email} in Keycloak.")
)
else:
self.stdout.write(
self.style.WARNING(f"✗ User {user.email} not found in Keycloak.")
)
return user, mailbox
def _create_shared_mailbox(self, local_part, domain):
"""Create a shared mailbox."""
email = f"{local_part}@{domain.name}"
mailbox_name = local_part.replace("-", " ").title()
# Create or get mailbox
mailbox, mailbox_created = models.Mailbox.objects.get_or_create(
local_part=local_part,
domain=domain,
defaults={
"is_identity": False, # Shared mailbox
},
)
# Create or get contact for the shared mailbox
contact, _ = models.Contact.objects.get_or_create(
email=email,
mailbox=mailbox,
defaults={"name": mailbox_name},
)
if not mailbox.contact:
mailbox.contact = contact
mailbox.save()
return mailbox
def _add_mailbox_access(self, mailbox, user, role):
"""Add or update mailbox access for a user."""
access, created = models.MailboxAccess.objects.get_or_create(
mailbox=mailbox,
user=user,
defaults={"role": role},
)
if not created and access.role != role:
access.role = role
access.save()
return access

View File

@@ -940,6 +940,20 @@ class Development(Base):
self.INSTALLED_APPS += ["django_extensions", "drf_spectacular_sidecar"]
class E2E(Development):
"""
End2End environment settings
Uses nginx reverse proxy to serve both frontend and backend on the same origin,
avoiding cross-origin cookie issues.
"""
CSRF_TRUSTED_ORIGINS = ["http://nginx", "http://keycloak:8802"]
# Trust X-Forwarded-* headers from nginx proxy
USE_X_FORWARDED_HOST = True
class DevelopmentMinimal(Development):
"""
Development environment settings with minimal dependencies

1
src/e2e/.dockerignore Normal file
View File

@@ -0,0 +1 @@
node_modules

6
src/e2e/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
test-results/
playwright-report/
/blob-report/
/playwright/.cache/
.auth

41
src/e2e/Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# E2E tests Dockerfile
# Use docker CLI image to get docker binaries
FROM docker:27-cli AS docker-cli
# Main image
FROM node:22-slim
ENV npm_config_cache=/tmp/npm-cache
ENV PLAYWRIGHT_BROWSERS_PATH=/tmp/playwright-browsers
ENV HOME=/tmp
RUN npm install -g npm@11.6.2 && npm cache clean -f
# Copy docker and docker-compose binaries from docker-cli image
# Create the cli-plugins directory first
ARG DOCKER_USER
RUN mkdir -p /usr/local/libexec/docker/cli-plugins
COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
COPY --from=docker-cli /usr/local/libexec/docker/cli-plugins/docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose
WORKDIR /app
# Install deps and run all npm commands as the user running the container
COPY package*.json ./
# Install dependencies and postinstall playwright with deps
RUN npm install
# Copy test files
COPY . ./
# Fix ownership for the user (use numeric UID to avoid lookup issues)
RUN chown -R $DOCKER_USER /tmp/npm-cache /app
# Run test with unprivileged user
USER $DOCKER_USER
# Default command
CMD ["npm", "test"]

52
src/e2e/README.md Normal file
View File

@@ -0,0 +1,52 @@
# Messages E2E Tests
End-to-end tests for the Messages application using Playwright.
## Prerequisites
- Docker and Docker Compose installed
- Messages project configured
## Running the tests
### In headless mode (CI)
```bash
make e2e-test
```
### In UI mode
```bash
make e2e-test-ui
```
Open the Playwright UI on http://localhost:8932 to write and debug the tests interactively.
### In Dev mode
Start playwright in UI Mode and use the dev frontend service to avoid rebuilding
the frontend after each change.
```bash
make e2e-test-dev
```
Open the Playwright UI on http://localhost:8932 to write and debug the tests interactively.
## Explanation
### Isolated services
E2E tests use [dedicated services](./compose.yaml) especially for the database and the object storage.
### Nginx to serve the frontend and the backend
Nginx is used to serve the frontend and the backend on the same origin, avoiding cross-origin cookie issues.
### Environment variables
E2E configuration files are located in `env.d/development/*.e2e`:
- `backend.e2e`: Backend configuration for tests
- `frontend.e2e`: Frontend configuration for tests
- `keycloak.e2e`: Keycloak configuration for tests

18
src/e2e/bin/backend-manage.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
# Helper script to run Django management commands in the backend container.
# Through the docker sock proxy service, it is possible to run commands in the
# backend container from within the runner container.
#
# Usage: ./backend-manage.sh <command> [args...]
# Examples:
# ./backend-manage.sh flush --noinput
# ./backend-manage.sh drop_all_tables
# ./backend-manage.sh migrate
# ./backend-manage.sh createsuperuser
set -e
# Run the Django management command in the backend container
docker compose -f /app/compose.yaml -p st-messages-e2e \
exec -T backend python manage.py "$@"

176
src/e2e/compose.yaml Normal file
View File

@@ -0,0 +1,176 @@
name: st-messages-e2e
services:
postgresql:
extends:
file: ../../compose.yaml
service: postgresql
ports: !reset []
redis:
extends:
file: ../../compose.yaml
service: redis
ports: !reset []
celery:
extends:
file: ../../compose.yaml
service: celery-dev
environment:
- DJANGO_CONFIGURATION=E2E
env_file:
- ../../env.d/development/backend.defaults
- ../../env.d/development/backend.e2e
depends_on: !override
- backend
opensearch:
extends:
file: ../../compose.yaml
service: opensearch
ports: !reset []
objectstorage:
extends:
file: ../../compose.yaml
service: objectstorage
volumes: !reset []
ports: !reset []
objectstorage-createbucket:
extends:
file: ../../compose.yaml
service: objectstorage-createbucket
keycloak:
extends:
file: ../../compose.yaml
service: keycloak
environment:
- HOST=http://keycloak:8802
- ADMIN_HOST=http://keycloak:8802
command:
- start-dev
- --features=preview
- --import-realm
- --proxy=edge
- --hostname=$${HOST}
- --hostname-admin=$${ADMIN_HOST}
- --http-port=8802
env_file:
- ../../env.d/development/keycloak.defaults
- ../../env.d/development/keycloak.e2e
ports: !reset []
depends_on: !override
- postgresql
backend:
extends:
file: ../../compose.yaml
service: backend-base
environment:
- DJANGO_CONFIGURATION=E2E
env_file:
- ../../env.d/development/backend.defaults
- ../../env.d/development/backend.e2e
depends_on: !override
postgresql:
condition: service_healthy
objectstorage:
condition: service_healthy
redis:
condition: service_started
opensearch:
condition: service_healthy
keycloak:
condition: service_started
mta-in:
extends:
file: ../../compose.yaml
service: mta-in
env_file:
- ../../env.d/development/mta-in.defaults
- ../../env.d/development/mta-in.e2e
ports: !reset []
depends_on: !override
- backend
nginx:
image: nginx:1.27-alpine
environment:
- E2E_PROFILE=${E2E_PROFILE:-e2e}
volumes:
- ./nginx/e2e.conf.template:/etc/nginx/templates/e2e.conf.template:ro
- ./nginx/entrypoint.sh:/entrypoint.sh:ro
entrypoint: "/entrypoint.sh"
depends_on:
frontend:
condition: service_started
frontend-dev:
condition: service_started
required: false
backend:
condition: service_healthy
frontend:
extends:
file: ../../compose.yaml
service: frontend-base
build:
target: frontend-build
args:
API_ORIGIN: http://nginx
command: ["npx", "serve", "-s", "out"]
ports: !reset []
frontend-dev:
profiles: [dev]
extends:
file: ../../compose.yaml
service: frontend-base
command: ["npm", "run", "dev"]
env_file:
- ../../env.d/development/frontend.defaults
- ../../env.d/development/frontend.e2e
volumes:
- ../frontend/:/home/frontend/
ports: !reset []
# Service to proxy the Docker socket to the tools container
# This is necessary to keep the tools container rootless
docker-sock-proxy:
image: alpine/socat
command: tcp-listen:2375,fork,reuseaddr unix-connect:/var/run/docker.sock
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
runner:
build:
context: .
dockerfile: Dockerfile
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
environment:
- FRONTEND_BASE_URL=http://nginx
- BACKEND_BASE_URL=http://nginx
- KEYCLOAK_BASE_URL=http://keycloak:8802
- DOCKER_HOST=tcp://docker-sock-proxy:2375
volumes:
- ./src:/app/src
- ./playwright.config.ts:/app/playwright.config.ts
- ../../env.d/development:/app/env.d/development:ro
depends_on:
docker-sock-proxy:
condition: service_started
nginx:
condition: service_started
mta-in:
condition: service_started
celery:
condition: service_started
command: npm run test
ports:
- "8932:8932"

View File

@@ -0,0 +1,57 @@
server {
listen 80;
server_name localhost;
# Frontend - serve on root
location / {
proxy_pass http://${FRONTEND_SERVICE_NAME}:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Backend API - serve on /api prefix
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
# Backend OIDC authentication
location /oidc/ {
proxy_pass http://backend:8000/oidc/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
# Backend admin (if needed)
location /admin/ {
proxy_pass http://backend:8000/admin/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Backend healthcheck
location /healthz/ {
proxy_pass http://backend:8000/healthz/;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
}

23
src/e2e/nginx/entrypoint.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/bin/sh
# Generate the nginx config from the template according to the E2E_PROFILE environment variable
# If the E2E_PROFILE is dev, use the frontend-dev service, otherwise use the frontend service
set -e
# Set FRONTEND_SUFFIX based on E2E_PROFILE
if [ "$E2E_PROFILE" = "dev" ]; then
export FRONTEND_SERVICE_NAME="frontend-dev"
else
export FRONTEND_SERVICE_NAME="frontend"
fi
# Generate nginx config from template
envsubst '${FRONTEND_SERVICE_NAME}' < /etc/nginx/templates/e2e.conf.template > /etc/nginx/conf.d/default.conf
echo "Generated nginx config for E2E_PROFILE=${E2E_PROFILE:-e2e} (FRONTEND_SERVICE_NAME=${FRONTEND_SERVICE_NAME})"
# Start nginx
exec nginx -g 'daemon off;'

97
src/e2e/package-lock.json generated Normal file
View File

@@ -0,0 +1,97 @@
{
"name": "messages-e2e",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "messages-e2e",
"version": "1.0.0",
"license": "MIT",
"devDependencies": {
"@playwright/test": "^1.48.0",
"@types/node": "^22.0.0"
}
},
"node_modules/@playwright/test": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
"integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.56.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "22.19.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz",
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.56.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

31
src/e2e/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "st-messages-e2e",
"private": true,
"version": "0.1.0",
"description": "End-to-end tests for Messages application",
"engines": {
"node": ">=22.0.0 <23.0.0",
"npm": ">=10.0.0"
},
"scripts": {
"test": "playwright test",
"test:ui": "playwright test --ui --ui-host=0.0.0.0 --ui-port=8932",
"test:headed": "playwright test --headed",
"test:debug": "playwright test --debug",
"codegen": "playwright codegen",
"report": "open ./playwright-report/index.html",
"postinstall": "playwright install --with-deps",
"django": "./bin/backend-manage.sh",
"db:flush": "npm run django flush -- --no-input",
"db:bootstrap": "npm run django e2e_demo",
"db:reset": "npm run db:flush && npm run db:bootstrap"
},
"keywords": ["e2e", "playwright", "testing"],
"author": "",
"license": "MIT",
"devDependencies": {
"@playwright/test": "1.56.1",
"@types/node": "22.19.1"
}
}

View File

@@ -0,0 +1,64 @@
import { defineConfig, devices } from '@playwright/test';
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './src/__tests__',
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: process.env.CI ? 'dot' : [['list'], ['html', { host: '0.0.0.0', port: 8932, outputDir: './src/__tests__/playwright-report' }]],
outputDir: './src/__tests__/playwright-report',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.FRONTEND_BASE_URL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
/* Screenshot on failure */
screenshot: 'only-on-failure',
/* Video on failure */
video: 'retain-on-failure',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
/* Run your local dev server before starting the tests */
webServer: process.env.SKIP_WEBSERVER ? undefined : {
command: 'echo "Waiting for services to be ready..."',
url: process.env.FRONTEND_BASE_URL,
reuseExistingServer: true,
timeout: 120 * 1000,
},
});

16
src/e2e/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"types": ["node", "@playwright/test"]
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,3 @@
node_modules
out
.next

View File

@@ -1,10 +1,26 @@
FROM node:22-slim AS frontend-deps
WORKDIR /home/frontend/
ENV npm_config_cache=/tmp/npm-cache
RUN npm install -g npm@11.6.2 && npm cache clean -f
ARG DOCKER_USER
USER ${DOCKER_USER}
WORKDIR /home/frontend/
ENV npm_config_cache=/tmp/npm-cache
RUN chown -R ${DOCKER_USER} /tmp/npm-cache
RUN chown -R ${DOCKER_USER} /home/frontend
# Install deps and run all npm commands as the user running the container
USER ${DOCKER_USER}
COPY --chown=${DOCKER_USER} package*.json ./
RUN npm install
FROM frontend-deps AS frontend-build
WORKDIR /home/frontend/
COPY . ./
ARG API_ORIGIN
ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
RUN npm run build

View File

@@ -10,7 +10,7 @@
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"start": "npx serve -s out",
"lint": "next lint",
"build-theme": "cunningham -g css,scss -o src/styles",
"api:update": "orval --config ./orval.config.ts",

View File

@@ -503,70 +503,7 @@
"realmRoles" : [ "user" ],
"notBefore" : 0,
"groups" : [ ]
}, {
"id" : "5385a8dc-1d4e-4d48-b67b-8cc393122323",
"username" : "user-e2e-chromium",
"firstName" : "E2E",
"lastName" : "Chromium",
"email" : "user@chromium.e2e",
"emailVerified" : false,
"enabled" : true,
"totp" : false,
"credentials" : [ {
"id" : "f67a812e-2989-4b0c-8e29-59d52877cd13",
"type" : "password",
"createdDate" : 1749245683785,
"secretData" : "{\"value\":\"984c+yrQ7Ss7gEUyBKG1hU1rHbs0fWvKh6Y2cykkWZ4=\",\"salt\":\"KQ6A8Ch4tpZkVmsE5tbthw==\",\"additionalParameters\":{}}",
"credentialData" : "{\"hashIterations\":5,\"algorithm\":\"argon2\",\"additionalParameters\":{\"hashLength\":[\"32\"],\"memory\":[\"7168\"],\"type\":[\"id\"],\"version\":[\"1.3\"],\"parallelism\":[\"1\"]}}"
} ],
"disableableCredentialTypes" : [ ],
"requiredActions" : [ ],
"realmRoles" : [ "user" ],
"notBefore" : 0,
"groups" : [ ]
}, {
"id" : "a6b2fb46-2db3-4d98-abea-7be68c2b81ac",
"username" : "user-e2e-firefox",
"firstName" : "E2E",
"lastName" : "Firefox",
"email" : "user@firefox.e2e",
"emailVerified" : false,
"enabled" : true,
"totp" : false,
"credentials" : [ {
"id" : "d94f507b-7a93-4f82-a29b-d3024da1e6cb",
"type" : "password",
"createdDate" : 1749245683848,
"secretData" : "{\"value\":\"qTc3ucpr7axbMM/ex++wM3K1S0OoLIZ9yAiZCog8ydY=\",\"salt\":\"AbBiB8zqIfy0fXsBr0zjbQ==\",\"additionalParameters\":{}}",
"credentialData" : "{\"hashIterations\":5,\"algorithm\":\"argon2\",\"additionalParameters\":{\"hashLength\":[\"32\"],\"memory\":[\"7168\"],\"type\":[\"id\"],\"version\":[\"1.3\"],\"parallelism\":[\"1\"]}}"
} ],
"disableableCredentialTypes" : [ ],
"requiredActions" : [ ],
"realmRoles" : [ "user" ],
"notBefore" : 0,
"groups" : [ ]
}, {
"id" : "7315d5ac-0cd5-4ce1-8759-4bf95d076ba1",
"username" : "user-e2e-webkit",
"firstName" : "E2E",
"lastName" : "Webkit",
"email" : "user@webkit.e2e",
"emailVerified" : false,
"enabled" : true,
"totp" : false,
"credentials" : [ {
"id" : "9d2e6d7d-21d0-4e3d-a7d1-40ac820435c4",
"type" : "password",
"createdDate" : 1749245683819,
"secretData" : "{\"value\":\"akhpAqR4qEgEAPpBng130u9xMNP1allIPOwx2Q3tCiw=\",\"salt\":\"THM80YVFqeGJmaXvb8izLA==\",\"additionalParameters\":{}}",
"credentialData" : "{\"hashIterations\":5,\"algorithm\":\"argon2\",\"additionalParameters\":{\"hashLength\":[\"32\"],\"memory\":[\"7168\"],\"type\":[\"id\"],\"version\":[\"1.3\"],\"parallelism\":[\"1\"]}}"
} ],
"disableableCredentialTypes" : [ ],
"requiredActions" : [ ],
"realmRoles" : [ "user" ],
"notBefore" : 0,
"groups" : [ ]
} ],
}],
"scopeMappings" : [ {
"clientScope" : "offline_access",
"roles" : [ "offline_access" ]
@@ -720,8 +657,8 @@
"alwaysDisplayInConsole" : false,
"clientAuthenticatorType" : "client-secret",
"secret" : "ThisIsAnExampleKeyForDevPurposeOnly",
"redirectUris" : [ "http://localhost:8900/*", "http://localhost:8901/*", "http://localhost:8902/*" ],
"webOrigins" : [ "http://localhost:8900", "http://localhost:8901", "http://localhost:8902" ],
"redirectUris" : [ "http://localhost:8900/*", "http://localhost:8901/*", "http://localhost:8902/*", "http://backend:8000/*", "http://frontend:3000/*", "http://keycloak:8802/*", "http://nginx/*"],
"webOrigins" : [ "http://localhost:8900", "http://localhost:8901", "http://localhost:8902", "http://backend:8000", "http://frontend:3000", "http://keycloak:8802", "http://nginx" ],
"notBefore" : 0,
"bearerOnly" : false,
"consentRequired" : false,
@@ -736,7 +673,7 @@
"access.token.lifespan" : "-1",
"client.secret.creation.time" : "1707820779",
"user.info.response.signature.alg" : "RS256",
"post.logout.redirect.uris" : "http://localhost:8900/*##http://localhost:8901/*##http://localhost:8902/*",
"post.logout.redirect.uris" : "http://localhost:8900/*##http://localhost:8901/*##http://localhost:8902/*##http://backend:8000/*##http://frontend:3000/*##http://keycloak:8802/*##http://nginx/*",
"oauth2.device.authorization.grant.enabled" : "false",
"use.jwks.url" : "false",
"backchannel.logout.revoke.offline.tokens" : "false",