Files
pl_mail/compose.test.yaml
T
Paul LütznerandClaude Opus 5.5 0056fc0981 Try the Tailwind build three times in the test stack
The first build on a fresh tree downloads the Tailwind binary from a
GitHub release. On the v0.3.2 tag GitHub answered that download with
a 500 in three of five Playwright jobs, app-init exited 255, and the
E2E run failed before any spec had run. The same code had passed on
main twenty minutes earlier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:08:23 +02:00

290 lines
15 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
# `in-memory://` for the three shared queues, which means a message routed
# to one is kept and never handled — every worker process is a container
# this stack does not run, and an in-memory transport is per-process
# anyway. That is deliberate and is what keeps the suite deterministic: a
# spec is never racing an AI classification, a mailbox resort or a rule
# pass that fired in the background.
MESSENGER_TRANSPORT_DSN: in-memory://
# The one exception, and the reason `bulk` has a DSN of its own in
# config/packages/messenger.yaml. A whole-view action HAS to actually run
# for bulk-select-all.spec.ts to mean anything — it selects the view,
# archives it and waits for the rows to go — so this queue is a real
# Postgres one with the worker below draining it. Pointing the shared
# variable here instead would switch the other three on at the same time.
#
# auto_setup=0: `messenger_messages` is created by a migration
# (Version20260625123836), and app-init has run it before anything else
# starts.
MESSENGER_BULK_DSN: doctrine://default?auto_setup=0
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
# Plain HTTP here, so the `__Secure-` name Mercure 1.0 defaults to would be
# dropped by the browser before it ever reached the hub. The hub is told the
# same name below.
MERCURE_COOKIE_NAME: mercure_access_token
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.
#
# Three goes, because the first build on a fresh tree downloads the
# Tailwind binary from a GitHub release, and GitHub answers that with a
# 500 now and then. When it did on the v0.3.2 tag, three of five
# Playwright jobs ended here, before a single spec had run, and the run
# went red with nothing wrong in the commit. A build that fails for a
# real reason fails three times and still stops the stack.
#
# Spelt out rather than looped: a shell variable in this file is
# Compose's to interpolate first.
php bin/console tailwind:build \
|| { sleep 5; php bin/console tailwind:build; } \
|| { sleep 20; 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
# RFC 9068: the issuer this hub trusts and the audience it demands, both
# matching what config/packages/mercure.yaml mints. See compose.yaml,
# which carries the reasoning.
MERCURE_TRUSTED_ISSUERS: https://plmail.invalid/.well-known/mercure
MERCURE_EXTRA_DIRECTIVES: |
resource_identifier https://plmail.invalid/.well-known/mercure
cookie_name mercure_access_token
# NO healthcheck here, deliberately — the image's own is used, exactly as
# in compose.yaml and for the same reason: the hub moved its readiness
# probe out from under the URL we had hard-coded, and every job in CI then
# failed to start for three days while the hub itself was fine.
#
# Tempting to keep just the old five-second interval and inherit the rest,
# and Compose will not have it: a healthcheck block without a `test` is
# rejected outright — "must define a healthcheck command for all
# healthchecks" — so the only way to own the cadence is to own the command
# again, which is the thing that rotted. The image asks every 30s instead
# of every 5s, so a job waits a few more seconds for the hub and no longer
# risks waiting for ever.
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
# Drains the one queue this stack makes real — see MESSENGER_BULK_DSN above.
#
# `--time-limit` is short because the stack is short-lived and a worker that
# has exited is easier to read in `compose ps` than one that is idle; the
# restart policy brings it straight back either way. `-vv` so a handler that
# throws says why in `compose logs worker-bulk`, which is the only window
# onto work that no longer happens inside a request the browser can see.
worker-bulk:
<<: *test-app
command: php bin/console messenger:consume bulk --time-limit=600 --memory-limit=256M -vv
restart: unless-stopped
stop_grace_period: 5s
# The image carries a HEALTHCHECK that curls the web server, which a worker
# is not. Left on, `compose up --wait` reports this container unhealthy and
# exits non-zero with the stack up and working — which in CI is a red run
# for a container doing exactly what it should.
#
# It was `disable: true` for that reason, and that swapped one red run for
# another: `up --wait` names this service (see .github/actions/test-stack,
# which explains why it has to), and a named service with no healthcheck at
# all makes Compose exit 1 with "container … has no healthcheck configured".
# Every E2E run since has been red before a single test executed. The
# answer is not to take the check away but to ask the right question — a
# worker is healthy when its consumer is running, which is what this reads.
#
# No `ps` and no `pgrep` in the image, so the process table is read where it
# actually lives. `messenger:[c]onsume` rather than `messenger:consume` is
# the old trick and it is load-bearing: the health probe's OWN command line
# is in /proc too, so a literal pattern matches itself and the check passes
# on a container whose worker died ten minutes ago.
healthcheck:
test:
- CMD-SHELL
- grep -lza 'messenger:[c]onsume' /proc/*/cmdline > /dev/null 2>&1
interval: 5s
timeout: 3s
retries: 6
start_period: 5s
depends_on:
database:
condition: service_healthy
app-init:
condition: service_completed_successfully
volumes:
app_var_test:
app_public_assets_test:
database_data_test: