Files
pl_mail/compose.test.yaml
T
Paul Lützner 56606f2c78 ci(e2e): the suite runs against the FrankenPHP the app ships on
CI served the browser suite with `php -S` on the runner, behind a router
script written for the occasion, with PHP and its extensions installed
beside it. Every accommodation in that workflow existed because of that
server and not because of the app: a php.ini copied into the scan dir with
sudo, PHP_CLI_SERVER_WORKERS, a router so index.php did not answer 56 asset
requests with 56 kernel boots, a copy of the environment into $_SERVER
because the cli-server SAPI provides none, and finally a restart loop
because the thing segfaulted mid-suite. A green run proved something about
a server no user will ever meet, while PHPUnit ran bare on the runner in
the same job that every developer ran in a container.

It now boots compose.test.yaml — the app's own image, from this
repository's Dockerfile — and runs `npm run test:unit:docker` and the
Playwright suite against it. Local and CI are one invocation.

Three things had to be true first.

app-init could not bootstrap a checkout that had never seen composer. It
overrides the image entrypoint to get the ordering right around the
database, and that entrypoint is also what installs dependencies into the
bind mount, so a fresh clone — or a worktree, or a CI runner — died on
"Dependencies are missing" before the stack came up at all. It installs
them itself now when vendor/ is absent.

admin-queue.spec.ts and mercure.spec.ts each carried a private copy of
`docker compose -f compose.test.yaml` with no -p, so both silently
addressed the DEFAULT project. Point the suite at a stack under any other
project name and the queue seed populated a database the browser was not
looking at, while the hub stop that mercure.spec.ts uses to prove the
stream recovers stopped a project that did not exist — which compose
reports as success. That is the whole of why admin-queue was believed to
need a running worker: it never did. `delivered_at` is what "a worker is
holding it" means to the transport, and app:test:seed-queue writes it.
Both now go through E2E_COMPOSE, resolved once in playwright.config.ts.

The image needed a stable tag so CI could build it ahead of time with a
layer cache, which is why compose.test.yaml names it rather than leaving
it to `<project>-<service>`.

Deleted: the webServer block, tests/e2e/support/router.php, the Postgres
service container, setup-php and its extension cache, the php.ini scan-dir
step, and the separate migrate/seed/asset steps the stack already does.
The server log still goes up with the report — from `docker compose logs`
now, and covering app-init and Postgres too, because when the failure is
that the stack never came up that is the only place the reason exists.

Verified against a compose stack from this worktree: PHPStan clean,
PHPUnit 2820 tests / 6979 assertions green, Playwright 253 passed and
0 failed — admin-queue and both mercure recovery tests among them.
2026-08-10 16:17:28 +02:00

199 lines
9.4 KiB
YAML

# The test stack — PHPUnit and the Playwright E2E suite.
#
# npm run test:unit:docker
# npm run test:e2e:docker
#
# Not only local: .github/workflows/e2e.yml boots this same file and runs those
# same two suites against it, which is the point. CI used to serve the app with
# `php -S` on the runner and install PHP beside it, so the two environments
# agreed about nothing except the source tree, and each had failures the other
# could not reproduce.
#
# Deliberately its OWN compose project (see `name:` below) with its OWN Postgres,
# so it can never reconcile, recreate or restart the dev stack, and can never
# reach the database holding real mail. `docker compose ...` without -f is
# untouched by this file.
#
# Served by the app's own FrankenPHP image rather than PHP's built-in server:
# variables_order is GPCS, so `php -S` never exposes APP_ENV to Symfony and the
# app silently falls back to .env.local and the dev database. A server SAPI puts
# the environment into $_SERVER, so the values below actually take effect.
name: pl_mail_test
x-test-app: &test-app
# The dev target, not the ghcr image: prod is built with `composer install
# --no-dev` (no PHPUnit) and ships a dump-env'd .env.local.php pinning prod.
build:
context: .
target: frankenphp_dev
# Named explicitly rather than left to compose's `<project>-<service>`
# default, for two reasons.
#
# `app` and `app-init` are the same image built from the same target, so the
# default naming had compose tagging it twice under two names. And the name
# stopped being predictable the moment anyone passed `-p` — a worktree on its
# own project got its own tags, which is why the one-off-container recipes in
# the handbook could not just name an image.
#
# Fixed, it is also a build CI can do ahead of time: .github/workflows/e2e.yml
# builds this tag with a layer cache and then brings the stack up without
# `--build`, which it could not do if the tag depended on the project name.
image: pl_mail_test-app
volumes:
- ./:/app
# A dedicated var volume. Never share the dev stack's `app_var`: it is a
# named volume overlaying the bind mount, so sharing it would let the two
# environments write each other's caches and built assets.
- app_var_test:/app/var
# Same trick, same reason, for the compiled assets `asset-map:compile`
# writes in app-init. `public/assets/` does not exist in the repo and the
# dev stack bind-mounts this very directory, so without an overlay the
# compiled output would land on the host and be served — stale — by the
# dev stack on every source edit, since Caddy prefers a real file over
# index.php. Keeping it in a volume means the test stack gets static assets
# and development is untouched by it.
#
# Docker does create an *empty* `public/assets/` on the host as the mount
# point. That is harmless and expected: the compiled files live in the
# volume, so the dev stack finds nothing there and falls through to PHP
# exactly as it did before. `.gitignore` already covers the directory.
- app_public_assets_test:/app/public/assets
environment: &test-env
APP_ENV: test
APP_DEBUG: "1"
# Pinned, and passed as real environment variables rather than left to
# .env.test: the entrypoint generates these when the environment does not
# carry them, and a generated value would win over the dotenv one. The
# suite wants them fixed — stable CSRF tokens, and a seed encrypted with
# the same key every run. Same values as .env.test.
APP_SECRET: 282065e0f71bf96c0dbcf0496c1f5d3e
APP_ENCRYPTION_KEY: emL6R8zs0pSXCVV2HvVvbFubriWJIla7kgnqSGqCJ8E=
# Host is `database`, unlike .env.test which targets 127.0.0.1 for CI.
# Doctrine's when@test dbname_suffix (config/packages/doctrine.yaml) turns
# this into `app_test` automatically.
DATABASE_URL: postgresql://app:app@database:5432/app?serverVersion=18&charset=utf8
MESSENGER_TRANSPORT_DSN: in-memory://
MAILER_DSN: null://null
APP_DEV_USER_EMAIL: e2e@plmail.test
APP_DEV_USER_PASSWORD: e2e-password-change-me
# Same-origin: the browser reaches the hub through the app's own Caddy
# (`handle /.well-known/mercure*` in frankenphp/Caddyfile), so the
# subscriber cookie is a first-party cookie and needs no CORS.
#
# http, not https, on purpose — Symfony derives the cookie's `secure` flag
# from this URL's scheme, so an https value here would mint a cookie the
# browser refuses to send back over the test stack's plain HTTP.
MERCURE_PUBLIC_URL: http://127.0.0.1:${TEST_HTTP_PORT:-8001}/.well-known/mercure
MERCURE_URL: http://mercure/.well-known/mercure
# Fixed, like APP_SECRET above: the suite signs subscriber JWTs with it and
# the hub verifies them, so both sides have to agree without depending on
# generated secrets that differ per run.
MERCURE_JWT_SECRET: e2e-mercure-secret-not-for-production
depends_on:
database:
condition: service_healthy
services:
database:
image: postgres:18-alpine
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: app
healthcheck:
test: ["CMD", "pg_isready", "-d", "app", "-U", "app"]
interval: 5s
timeout: 5s
retries: 10
volumes:
- database_data_test:/var/lib/postgresql
# Owns schema, assets and the seed user, then exits. `app` waits for it.
app-init:
<<: *test-app
# The image entrypoint waits on the database and runs migrations *before*
# the database exists, so it cannot bootstrap an empty volume. Drop to the
# upstream PHP entrypoint and do the ordering here.
entrypoint: ["docker-php-entrypoint"]
restart: "no"
command:
- sh
- -c
- |
set -e
# The entrypoint this service deliberately skips is also the thing that
# installs PHP dependencies into the bind mount (see the long note in
# frankenphp/docker-entrypoint.sh). Dropping to docker-php-entrypoint
# bought correct ordering around the database and lost that, which was
# invisible for as long as everyone running this stack already had a
# vendor/ from the dev stack. On a tree that never had one — a fresh
# clone, a new worktree, a CI checkout — every bin/console below died on
# "Dependencies are missing. Try running composer install." and the
# stack never came up at all.
#
# No lock around it, unlike the entrypoint's version: this service runs
# on its own and `app` waits for it to exit, so there is no second
# container racing for the same vendor/.
[ -f vendor/autoload_runtime.php ] || composer install --prefer-dist --no-progress --no-interaction
php bin/console doctrine:database:create --if-not-exists
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
php bin/console importmap:install
# Not optional: specs assert toBeVisible()/toBeHidden(), which depend on
# Tailwind's `.hidden { display: none }`. Building here also keeps this
# stack independent of the dev `tailwind` watcher.
php bin/console tailwind:build
# Compiled, not served through PHP. Without this, `try_files {path}
# index.php` in frankenphp/Caddyfile sends every asset request into a
# full Symfony kernel boot: /login alone pulls 54 modulepreload entries
# plus 2 stylesheets, measured at ~35ms each against ~1.6ms for a real
# static file. Playwright gives every test a cold HTTP cache, so that
# was ~1.9s of PHP spent serving JavaScript once per test — about 40%
# of the whole e2e suite.
php bin/console asset-map:compile
php bin/console app:test:seed-user
# The admin the PHPUnit controller tests look for. Seeded here rather
# than left to the Playwright suite, which is where it used to come
# from: PageRendersTest and friends skip themselves when the user is
# missing, so `npm run test:unit:docker` on a fresh volume quietly ran
# ~50 fewer tests than it appeared to until the browser suite had been
# run at least once.
php bin/console app:test:seed-user --admin --email=e2e-admin@plmail.test --password=e2e-admin-password
# The real hub, so the suite can assert on live updates and on recovery after
# the stream drops. Runs the image's DEFAULT Caddyfile, not dev.Caddyfile:
# the default one requires a subscriber JWT, which is what production serves,
# so the tests exercise the authorization path rather than an anonymous hub
# that would accept anything and hide a broken cookie.
mercure:
image: dunglas/mercure
environment:
SERVER_NAME: ":80"
MERCURE_PUBLISHER_JWT_KEY: e2e-mercure-secret-not-for-production
MERCURE_SUBSCRIBER_JWT_KEY: e2e-mercure-secret-not-for-production
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/healthz"]
interval: 5s
timeout: 5s
retries: 10
app:
<<: *test-app
environment:
<<: *test-env
# Plain HTTP on 80 inside the container — no TLS, no cert prompts.
SERVER_NAME: ":80"
ports:
- "${TEST_HTTP_PORT:-8001}:80"
depends_on:
database:
condition: service_healthy
app-init:
condition: service_completed_successfully
mercure:
condition: service_healthy
volumes:
app_var_test:
app_public_assets_test:
database_data_test: