14 Commits
Author SHA1 Message Date
Paul Lützner e23377eb6a Migrate test databases with app:db:migrate so the cache tables exist (#225)
doctrine:migrations:migrate leaves cache_items out; without it the first cache read inside a test's transaction aborts it, so ThreadSummaryTest and the Gmail import tests failed whenever nothing had created the table first.
2026-10-19 13:51:50 +02:00
Paul LütznerandClaude Opus 5.5 ffbf7bdd9b Clean up the compose files (#160)
Compose files keep only a short header; the reasoning lives in
docs/install. truenas.compose.yaml is gone (the TrueNAS catalog app
replaces it) and so is compose.prod.yaml (compose.yaml already runs the
published prod image). compose.override.yaml.dist becomes compose.dev.yaml,
passed with -f, so there is no untracked copy that compose loads on its
own. Docs, CODESTYLE, CONTRIBUTING and the entrypoint's error text follow.

Every remaining file resolves to the same `docker compose config` as
before.

Closes #30

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:43:22 +02:00
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
Paul LütznerandClaude Opus 5 b38aaac04e Give the Mercure tokens an identity no installation has to configure
v0.2.31 could not mint a token at all on a TrueNAS install, and nothing an
operator set would have fixed it.

That release bound `iss` and `aud` to MERCURE_PUBLIC_URL. truenas.compose.yaml
leaves that BLANK ON PURPOSE — the setup screen asks for the public URL on first
boot and stores it in the database, which is long after the hub container has
had to be told what to trust. An empty issuer is not a weak issuer: the factory
refuses it outright with `The "iss" additional claim is required by RFC 9068
access tokens`, so there was no subscriber cookie, every publish threw, and the
symptom was a red dot indistinguishable from a hub that was simply down.

The mistake underneath was deriving a value that must exist at CONTAINER START
from one that only exists after SETUP. So it is a literal now, shared by the
application and every compose file, and no installation has to know it. An
identifier does not have to resolve — `.invalid` is reserved by RFC 2606 for
exactly this — and all that matters is that both halves agree on the same
string, which a literal guarantees and a derived value cannot. The same string
everywhere costs nothing: the signing secret is generated per installation, so
one install's token has never been usable against another's hub however it
identifies itself.

truenas.compose.yaml had been left behind twice more. It still carried the
"/healthz" check that v0.2.30 replaced in the other two files, so its hub would
have gone unhealthy the moment the image moved; and it had no pull_policy, so
the image never moved at all. That is the combination that produced the original
report: an application on the 1.0 protocol talking to a 0.x hub that nothing had
ever pulled.

The regression test empties MERCURE_PUBLIC_URL rather than reading the config,
because what broke was the derivation — a test that read the same placeholder
the code did would have passed throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 13:09:36 +02:00
Paul LütznerandClaude Opus 5 d35418c40a Speak the Mercure 1.0 protocol, because the hub does now
Live updates were dead: every subscription came back 401 and the inbox sat on
"Live updates unavailable" while the hub answered its own healthcheck happily.
The hub had moved to the 1.0 protocol and now wants RFC 9068 access tokens — an
`iss` it has been told to trust, an `aud` naming the hub the token is for, and
`authorization_details` in place of the bare `mercure` claim. plMail was minting
the older shape.

NOT HAND-ROLLED CLAIMS. symfony/mercure already implements 1.0, so this is
configuration: `protocol_version: '1.0'` plus the three claims the bundle
requires (it refuses to build the container without `iss`, `sub` and
`client_id`, which is how the requirement was found). They are merged into the
token FACTORY as well as the publisher provider, so the subscriber cookie gets
them too — one block covers both halves.

The protocol switch also changes the subscription query parameter from `topic`
to `match` and the cookie's name. Both belong to the bundle — the `mercure()`
Twig helper writes the URL, Authorization writes the cookie — which is why no
JavaScript and no controller is touched here.

`resource_identifier` IS PINNED ON THE HUB, and that is not decoration. A 1.0
hub works its expected audience out from each request, and plMail reaches it by
two roads: the browser subscribes at MERCURE_PUBLIC_URL, the application
publishes to the internal MERCURE_URL. One token cannot name both, so a hub
left to derive its own answer would refuse every publish. Issuer and audience
are both read from MERCURE_PUBLIC_URL so the app and the hub cannot be
configured into disagreeing.

THE `__Secure-` COOKIE is the one thing an operator may have to know about. 1.0
names the cookie `__Secure-mercure_access_token`, and a browser drops a
`__Secure-` cookie that did not arrive over TLS. Right for every real install,
fatal for the test stack, which serves plain HTTP on 127.0.0.1 — so the name is
a parameter defaulting to the secure one, and only the test stack overrides it,
with the hub told the same name.

Verified in a browser against the current hub image: the indicator reads
"connected", and the subscription is GET /.well-known/mercure?match=mail%2Fuser
%2F1 → 200. Full suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 12:12:36 +02:00
Paul LütznerandClaude Opus 5 5c667fb4f8 Let the Mercure image say whether the Mercure image is healthy
Follow-up to eca0f8b7, which pointed our healthcheck at the hub's new
readiness path. Correct, and still the wrong shape: it leaves plMail asserting
something about the internals of an image that is deliberately unpinned so it
can update itself. The next time the hub moves that path, CI goes red for
another three days.

The image has always shipped its own HEALTHCHECK — wget against
127.0.0.1:2019/mercure/health/ready, 60s grace, 5s timeout, 5 retries — which
is the same grace and retries compose.yaml was spelling out by hand. Inheriting
it costs nothing there and cannot drift, because it travels with the image it
describes.

The test stack wanted to keep its five-second interval and inherit the command,
and Compose refuses: a healthcheck block without a `test` is rejected with
"must define a healthcheck command for all healthchecks". So owning the cadence
means owning the command again, which is the thing that rotted. It asks every
30s now — a few more seconds before a job starts, against never again waiting
for ever.

Verified against the current image: the running container carries the image's
healthcheck verbatim and reports healthy, and the test stack comes up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:42:29 +02:00
Paul LütznerandClaude Opus 5 eca0f8b74d Ask the Mercure hub for its health where it answers
Every E2E job has failed since 18 September with "dependency failed to start:
container pl_mail_test-mercure-1 is unhealthy". The hub was fine. Our
healthcheck was not.

dunglas/mercure is pinned to a floating tag, and the image moved its readiness
probe. "/healthz" now answers 404; `curl -f` treats a 404 as failure; the
container is marked unhealthy for ever while serving every subscription put to
it. Verified against the current image: /healthz gives 404, and
127.0.0.1:2019/mercure/health/ready gives 200 — which is the path the image's
OWN healthcheck has always used, and a container left to use it reports healthy.

Overriding the image's healthcheck with a URL of our choosing is precisely what
let the two drift apart, so the fix is to ask where it asks.

compose.yaml carried the same line, and that half was shipped. Nothing there
depends on the hub being healthy, so live updates kept working and the symptom
was an install permanently showing "(unhealthy)" in docker ps and in the
TrueNAS view — about nothing at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:38:21 +02:00
Paul LütznerandClaude Opus 5 14c5d58d8c Give the bulk worker a healthcheck it can actually pass
Every E2E run since 2026-09-11 has been red before a single test executed —
v0.2.22 through v0.2.27 and every dependabot pull request — and the cause is
two lines that each fixed the other's problem.

The image carries a HEALTHCHECK that curls the web server. A worker is not a
web server, so `compose up --wait` reported the container unhealthy and exited
non-zero with the stack up and working; the answer at the time was
`healthcheck: disable: true`. But .github/actions/test-stack names worker-bulk
in that same `up --wait` — it has to, because a consumer of a queue the app
writes to is a dependent and compose cannot infer that it matters — and a named
service with NO healthcheck makes Compose exit 1 with "container … has no
healthcheck configured". One red run traded for another, and the second one
looks like an infrastructure hiccup rather than a config error, which is
presumably how it survived six releases.

So it gets a check that asks the right question instead of no check at all: a
worker is healthy when its consumer is running. There is no `ps` and no `pgrep`
in the image, so the process table is read where it lives, in /proc.

`messenger:[c]onsume` rather than `messenger:consume` is the old trick and it
is load-bearing here. The probe's own command line is in /proc too, so the
literal pattern matches itself and the check passes on a container whose worker
died ten minutes ago — verified: the literal form returns 0 in the app
container, which runs no consumer at all, and the bracketed form returns 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 16:00:23 +02:00
Paul LütznerandClaude Opus 5 ab9ecbc9e0 Hand a whole-view bulk action to a worker, which it never was
RunBulkStatusHandler exists to take a mark-read or archive over a whole view
out of the request — its docblock quotes the thirty-second timeout that
produced it, and BulkStatusController answers with an indicator so nobody
waits. Neither was true: the message was never listed in messenger.yaml's
routing map, and Messenger handles an unrouted message inline in whichever
process dispatched it. Every whole-view action ran inside the web request,
under the limit it was written to escape, at request concurrency — which is
where the production deadlock came from.

It gets a queue of its own rather than joining maintenance, because somebody
is watching an indicator for it and queueing it behind an embedding backfill
would be slower than the inline version it replaces. That means one new
container, worker-bulk, in every compose file and in the handbook.

The deadlock itself is retried now instead of failing the job, three times per
chunk, and a collision that outlasts those hands the job back to the queue
rather than ending it. The failure path no longer writes through an
EntityManager Doctrine has closed, which is what it was doing for every
failure that came out of a flush.

And the list catches up: nothing ever refreshed it on the strength of the job,
only on the toolbar's own "the write landed" event, which is now the instant
the work was QUEUED rather than finished.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:18:57 +02:00
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
Paul LütznerandClaude Opus 5 f111297afd Make live updates survive the hub going away, and say when they have not
Three faults, one story: the stream could die silently, nothing told the
user, and nothing tested any of it.

**It did not recover.** EventSource retries on its own only when a
connection drops cleanly. An HTTP error response is fatal per spec —
readyState goes to CLOSED and the browser never tries again — and our
Caddy answers 502 for the seconds a hub restart takes. Measured: an open
stream, a 20s outage, and the tab stayed dead after the hub was back.
Every deploy did this to every open tab. There is now a reconnect loop
with jittered backoff, plus an immediate retry when a tab is refocused
or the network returns.

**Nothing said so.** The mail list has no polling fallback, so a dead
stream looks exactly like a quiet mailbox — the inbox stops changing and
nothing on screen explains why. A glow behind the logo now carries the
state, quiet when healthy, and announced rather than colour-only. While
the stream is down the list falls back to a 60s poll: strictly the worse
mechanism, which is why it runs only when the good one is unavailable.

**The hub accepted anyone.** dev.Caddyfile was in use in every
environment including TrueNAS, and it sets `anonymous`. Since
/.well-known/mercure* is proxied publicly, anyone who could reach an
instance could subscribe to mail/user/<any id> and watch that account's
sync activity. Both stacks now run the image's default config, which
requires the subscriber JWT.

That JWT expires — an hour by default, well inside a remember-me
session — and the hub only checks it when a connection is made, so the
expiry is invisible until the next reconnect is refused. /mercure/auth
re-issues the cookie, and the client refreshes it before every retry.

The cookie is attached by a response subscriber rather than by
`mercure(topics, {subscribe: …})` in the layout, because that helper
throws for a hub on another second-level domain and a throw in a
template takes the whole page with it. PHPUnit caught it at once: its
client requests as localhost against a 127.0.0.1 hub, and every
page-rendering test turned 500. An external hub is a legitimate
deployment; it should cost live updates, not the page.

Tests: compose.test.yaml grew a real hub, running the same non-anonymous
config as production so the suite exercises the authorization path
instead of one that accepts anything. The recovery spec stops the
container — `setOffline` does not sever an established SSE connection
and route interception never fires without a request, so both looked
like passing tests of nothing. It lives in the exclusive project: there
is one hub, and taking it away mid-run breaks specs that never asked.

Verified by disabling the reconnect and watching the recovery test fail,
then restoring it and watching it pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 21:45:13 +02:00
Paul Lützner 7ec1f56ee7 test speedup 2026-07-31 08:37:13 +02:00
Paul Lützner 122006460d onboarding. this is a lot 2026-07-30 16:40:31 +02:00
Paul Lützner d0685d0ee6 update 2026-07-28 12:33:46 +02:00