12 Commits
Author SHA1 Message Date
13303b247e v0.3.9 milestone (#283)
* Run the end-to-end suite on pull requests into milestone and wip branches

A release's work is now collected on a milestone branch (milestone/v0.4.0)
before it reaches main, and the suite only ran on pull requests into main,
so nothing proposed for the milestone was tested. `milestone/**` joins
`main`, and `wip/**` comes along from wip/28, where it already runs the
suite on the phases of #28 — the same block on both, so they merge
without a conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit d009ff72ad)

* linked-issue: read the description of a pull request into a milestone branch (#254)

GitHub links an issue from "Closes #N" only in a pull request into the
default branch. Into milestone/v0.3.8 or a wip/ branch the closing
references stay empty, so the check failed on every such pull request
however its description named the issue (#252 was the first). For a pull
request into another branch, the description is now read for the same
keywords GitHub accepts.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* The release pull request is the suite run behind a tag (#252)

Releasing asked for the E2E workflow to be started by hand on main before
a tag. Since main became protected, the Release vX.Y.Z pull request cannot
merge until that workflow is green on it, and its commit is what is tagged,
so a manual run only ran the same jobs twice. Releasing now says so, and to
update the branch if main moved after its checks ran, since the protection
does not ask for up-to-date branches.

The paragraph in CONTRIBUTING and the header of e2e.yml that said main is
not verified commit by commit describe main before it was protected; both
now describe it as it is.

Closes #251

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Calendar on a phone: a readable month, a toolbar and top bar that fit, a live now line (#256)

The month chip is its title first, on up to two lines, then its time range
and place; a narrower day drops the end and the place for a third line of
title, long words are hyphenated, and an unanswered invitation has a
dashed bar. Below 39rem, where a day is bars, the grid shrinks and the
chosen day is listed in full under it: today at first, any tapped day
after, with a + for that day that the toolbar's + follows too. On a wider
touch screen the first tap opens a day's card and the second its entry
(calendar--month-tap, and a day-open: variant that the hover and the tap
share).

The calendar toolbar fits below 37rem with icons, a short date label and
smaller buttons, and under 22rem today's date in a box for Today. English
writes a same-month week as "Oct 20-26" (range_short_month). The top bar
tightens below 640px and moves Sync now, What's new and Send feedback into
the user menu. Dialogs are the whole screen below 768px, as compose is.

calendar--now moves the now line every minute on the view's clock and
redraws a view that shows the old day or the new one when the date
changes, which also moves the month's circle on today.

And a + on a day that has gone opened the editor on today:
DefaultEventStart::onDay pulled any past day to the next hour. A past day
now keeps its date.

Closes #253

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Close an issue when its pull request is merged into a milestone branch (#260)

GitHub acts on "Closes #N" only for merges into the default branch, so
issues fixed on a milestone branch stayed open until the release reached
main. A workflow on merged pull requests into milestone/** now closes the
issues the description names, with a comment linking the pull request.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Preview the reply, not the quote, and send replies with their quote visible (#258)

The list preview flattened the whole body and skipped short blocks to find
the first long one, so a one-line reply previewed as its attribution line or
the quoted mail. The quoted history (same seam as the reading view's "···")
and a "-- " signature are cut off first now, in HTML and plain text, and the
chrome skip only applies to bulk mail. The JMAP preview uses the same code.

The composer folded the quote with an inline display: none and its clean-up
removed only the toggle, so replies went out with an invisible quote. The
fold is removed in the editor and again on the server, and the reading view
reveals a folded quote in mail already received.

Closes #257

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Move a thread's unread count when JMAP marks a message read or unread (#267)

EmailPatchApplier wrote seenAt for $seen and left MessageThread::$unreadCount alone, so the web list (which draws bold from that counter) disagreed with the phone app. $seen now goes through ThreadStatusUpdater::markRead(), the web UI's own path, and markRead() counts the whole thread rather than the messages passed in.

Closes #266

Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Leave a message that is already in the destination folder where it is (#270)

Trashing a conversation with a message already in the bin queued that message as a move from Trash into Trash. webklex's move() then left the connection in EXAMINE and the server refused it ("NO Not permitted in EXAMINE state"), and the row had already lost its UID. ApplyImapFlagsHandler no longer sends a move into the folder a message is in (and gives the row its UID back), and ThreadStatusUpdater's trash(), archive() and restore() leave such messages out of the move.

Closes #269

Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Hide a conversation from the lists only when all of it is in the bin (#272)

A thread's labels are the union of its messages', so one deleted message put Trash on the thread and took the whole conversation out of the Inbox, its tab, label lists, All Mail, Unread, the badges and search, along with any reply threaded into it afterwards. The bin rule is now "carries Trash and has no message outside it", written once (binnedThreads()) for every list and badge and spelled out the same way in the search SQL. Option (b) of the issue.

Closes #268

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Let a long message scroll in the expanded compose window (#273)

expandedValueChanged() used closest('div') to reach the editor's wrapper, but closest() starts at the element itself and the editor is a div, so the wrapper never became a flex container and the editor stayed 170px tall, clipped by [contain:paint]. The wrapper is now the flex column and the editor grows (1 0 auto) to fill the window or its content; the plain-text field fills the expanded window too.

Closes #262

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Write each changelog entry as a file of its own, collected at release (#275)

Every pull request added its paragraph under ## Unreleased in CHANGELOG.md at the same place, so the second of any two conflicted with the first once it merged, and each conflict fix re-ran the whole E2E suite. A pull request now adds changelog.d/<issue>-<words>.md; bin/changelog-release.sh, run in the Release vX.Y.Z pull request, collects the fragments (highlights first) and anything still under ## Unreleased into the release's section and deletes them. CHANGELOG.md stays the one file What's new, bin/release-notes.sh and GitHub read.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Say which step failed when notifications can't be turned on (#278)

Every failure in the enable path (registering the service worker, the
browser's push subscription, the server's answer) ended in the same
"Something went wrong" and the exception was dropped, so a report from a
Pixel 8 could not say which. The pane now names the step and the
DOMException name or HTTP status, and logs the exception to the console.

Closes #277

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Contacts: one address book, with Google, Outlook, Nextcloud and CardDAV contacts in it (#282)

A contacts page and pane for plMail's own contacts and the ones on a server:
Google and Outlook on the mail account's own sign-in, Nextcloud through the
Nextcloud connection, any CardDAV server through Settings -> Contacts. Every
field a phone keeps, the photo included, syncs both ways. Duplicates merge
with 90 days to undo; vCard and CSV import and export; contacts suggested
while composing; JMAP Contact/get, /query and /set. Nextcloud calendars come
with the Nextcloud connection too.

Replaces #237 and #109 by @jo2904 and #244 by @DanversKara.

Closes #157
Closes #236

Co-authored-by: jo2904 <jo2904@users.noreply.github.com>
Co-authored-by: DanversKara <DanversKara@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Install APCu and Xdebug with PIE instead of from PECL (#286)

PECL is deprecated in favour of PIE and was down for most of an evening on 2026-10-21, failing every image build. PIE 1.5.2 now installs APCu (and Xdebug in the dev image) from the extensions' own releases; it is mounted for that one step and not left in the image. Nothing changes at runtime.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Keep contact photos where the web container can serve them (#287)

Contact photos were stored in var/contacts, outside the volumes the web and worker containers share, so synced photos answered 404. They now live under APP_STORAGE_DIR/uploads/contacts; a migration clears the lost paths and has synced books read again so the photos come back.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Match a contact's email addresses in any case (#288)

Addresses are stored lower-cased but were matched as the provider spelled them, so a pull removed and re-inserted the same address and broke the unique key (prod sync failure). They are now matched in their stored form.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Open a contact's reference email in the mail pane (#294)

A click on one of a contact's reference emails in the docked contacts
pane was a Turbo visit inside the contacts frame, which blanked the mail
pane. The click is now offered to the mail pane as a mail-pane:open
event; the pane opens the message beside the contact. Without a mail
pane (the full contacts page) it stays an ordinary visit.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Open mail beside the full Contacts page without a reload (#297)

The full Contacts page was a page of its own, so a reference email
clicked there was a visit: the contact was gone, and on a notebook the
mailbox came back on the calendar it remembered.

The full page is now the mailbox with data-contacts-full on the shell:
the mail column put away and the contacts dock given the row, with the
page's own content in the dock's frame. Opening a message from it loads
the message into the existing mail pane, then contacts--pane#reveal
drops the state in a view transition: the mail slides in from the left
and the dock narrows to its width, the same DOM throughout. On a
notebook the calendar gives way; on a wide screen it stays.

The reference links lose data-turbo-frame="_top", which made ui--split
stash a calendar position for a visit that never came.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Preview image and video attachments on click, like PDFs (#295) (#298)

Clicking an image or video attachment chip opens a viewer in the modal
instead of downloading. Previewable chips (PDF included) get a download
half on the right. Images load from the existing attachment route; videos
from a new /mail/attachment/{id}/video route that serves a fixed video
type inline under the sandbox CSP and answers Range requests.

Closes #295

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Erase a message's parts with it, and delete mail one way (#300)

MessageEraser loaded a message's parts to delete their files, then removed
only the message. After the delete Doctrine forgot the message but kept the
parts managed, so the next flush in the same unit of work failed with "a new
entity was found through the relationship MessagePart#message". Every sync
that removed server-deleted mail with an attachment failed that way.

The eraser now removes the parts, removes a thread it empties (announced to
JMAP as destroyed), and logs file-delete failures instead of keeping the row.
MessagePurger hands its local delete to the eraser, so "delete forever" gains
the thread-label re-sync it lacked. ImapAccountSyncer clears an entity manager
a failed folder left open, so one folder's refused flush no longer fails the
rest.

Closes #299

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Rework the contact editor: quick add, paste details and Improve with AI (#305) (#306)

New contacts start in quick add: paste a name, signature, address line or
vCard and the details it finds are listed, each with a one-click type. The
editor shows the name as one field whose parts fill themselves (titles and
degrees included), rows that appear as you type, Paste details for existing
contacts and a Save to picker. Contact help is a new AI feature, off by
default, that rereads pasted text and suggests updates from the signature
of a contact's newest mail. Phones are read with libphonenumber.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Keep spam off the radar and out of search; add a Not spam button (#308)

Cards read out of mail in Spam are left off the Happening Soon panel and
the mailbox strip (read-time filter, so moving the mail out brings them
back), and a spam conversation no longer shows its cards above the
message. The bin keeps its cards. Search and the type-ahead leave Spam
out unless asked with in:spam, as they already did for the bin. A spam
conversation's toolbar gets a Not spam button that moves it to the inbox.

Closes #304

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Add to contacts asks new or existing; contacts button gets the calendar's three positions (#312)

Closes #309
Closes #311

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show the pictures a review request hid, dropped or never asked for (#274)

* Show the pictures a review request hid, dropped or never asked for

A BAUHAUS review request, sent through Bazaarvoice, showed neither its
logo nor its product photos, with images allowed. The image proxy was
fine; three steps around it were not.

- The logo is linked as http://. The sanitizer keeps http sources and
  leaves them to render time, the fetcher only connects over TLS, and
  nothing in between upgraded the URL, so every http image became the
  placeholder. RemoteContentBlocker now signs the https:// form. The
  fetcher's own rules also say in the log which one refused a target;
  until now that refusal was the one silent path.
- The first product card sits inside a <form>, and dropElement('form')
  took the photo, the name and the star links with it. The form is
  unwrapped now; textarea and select still go with their content.
- Each card is class="d" style="display:none", switched on with
  .d {display:block !important}. CssToInlineStyles keeps an inline
  property whatever the rule says, so the card stayed hidden next to its
  Outlook twin. Html5CssInliner now lets a stylesheet !important beat a
  plain inline style, and matches selectors against forms as the divs
  the reader will see, so `form .a` rules take the sender's fallback
  for clients without forms.

  app:backfill safe-html-hidden-cards

re-sanitizes stored mail with a form or a stylesheet !important. One
task for both, because neither fix brings the card back alone.

Closes #271

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Mark the German mail page as translated from the current English one

Both pages gained the same two paragraphs in the previous commit, but
the translated-from hash still named the English page before them, so
DocumentationCoverageTest reported the translation as stale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Credit the image proxy entry and keep it as a changelog fragment

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Paul Lützner <karatektus@gmail.com>

* Swipe a mail row, choose what each swipe does, and long-press for its actions on a phone (#314)

On a phone, below md and by touch only: a row swipes right for read/unread and left for archive, each direction set under Settings → Mail → Swipe actions (read/unread, archive, star, nothing; never trash), and holding a row opens an action sheet with every action its strip offers. Mouse, trackpad and wider windows are unchanged.

Takes over #117, #118 and #119, whose fork was archived. Also gives the Contacts settings' two cards their gap.

Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Admin → Integrations lists the calendar services, so a pinned CalDAV address can be changed later (#279)

CalDAV and Calendar feed get their own group below the file services, with the shared provider row and Configure form; Nextcloud stays with the file services. Calendar services are offered to everyone, so their rows say so and the form has no switch. The server address accepts only http or https, up to 512 characters.

Once people have connected, the CalDAV form says how many connections a changed or cleared pin reaches. A calendar feed offers its address only while one is stored, so it can be cleared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Offer an address book another connection already mirrors locked, not as new (#319)

Two connections to the same server, a Nextcloud one and a CardDAV one made by hand, reach the same address book under the same remote id. Settings → Contacts now offers such a book locked, saying which connection has it, and refuses a posted tick, as it does for calendars. Otherwise every contact in it showed twice.

Closes #317

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gmailify: the IMAP folder places a sibling's mail, Gmail deletions detach, and a switch to turn it off (#204)

Closes #203
Closes #205

- A sibling's IMAP copy keeps the placement its folder gives it; Gmail's INBOX/SENT/DRAFT/TRASH/SPAM no longer move it (by @phdelodder).
- Gmail deleting its copy detaches the sibling's IMAP copy instead of erasing it.
- app:backfill gmailify-placement repairs copies the old merge had moved.
- Settings → Aliases: a Gmailify switch per Gmail account. Off detaches carried copies in a background job and declines mail Gmail still collects for another account (Bcc'd mail excepted, as documented).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Gmail sync: look up only the listed ids, not every stored one (#321)

Part of #223: newGmailIds() asks only about the ids Gmail just listed, and nothing when none were.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Store whether a conversation is in the bin, kept by triggers (#322)

Part of #223: message_thread.binned holds #268's rule, kept by triggers on message_label, thread_label and message; lists, badges and search read the column. Identical results on 41 compared queries, most 5-100x faster on a 67k-thread mailbox.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Account sync: wait out a provider that can't be reached, quietly (#333)

When the worker couldn't connect to the Gmail or Graph API at all, the
account sync went up Messenger's retry ladder: a WARNING with three nested
stack traces per attempt, then CRITICAL and a parked job if the outage
lasted. That job resumes from a cursor and is asked for again every
quarter hour, so nothing was ever lost.

WaitOutConnectionFailures now puts a SyncAccountMessage that failed only
with an HttpClient TransportException back on its own queue, on the
ingest ladder's delays, with one info line per attempt. After five waits
it is dropped with a single warning. Message batches and every other
failure keep the normal path.

Closes #332

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Sven Joder <sj@pmdevelopment.de>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com>
Co-authored-by: jo2904 <jo2904@users.noreply.github.com>
Co-authored-by: DanversKara <DanversKara@users.noreply.github.com>
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
2026-10-23 11:18:21 +02:00
Paul LütznerandClaude Opus 5 67c3b52376 fix(compose): a cancelled send no longer reopens a window you closed
In the held send shape the composer deliberately stays on screen while a send is
out, so every second of that is a second the close button is live. Cancel the
send, then press Save draft and close, and the undo response — which answers
with the composer PUT BACK, because that is its job — replaces the frame you
just emptied. The window returns by itself and nothing takes it away again.

Reproduced deterministically by holding the undo response and closing
underneath it, rather than waiting for a full-suite run to be slow enough.

Fixed on the client because it cannot be fixed anywhere else: the close is
entirely client-side in this state — saveDraft() returns early while sending —
so the server never learns the window is gone, and the response is already
being generated. A cancelled send now drops only the stream aimed at this
window's own frame; the inline variant still takes the sent mail back out of
the conversation, which is on screen and genuinely wrong until it runs.

Nothing is lost by dropping the reopen: the send has already been called off
server-side and the message is a draft again, which is what the reader asked
for. The optimistic path is untouched on purpose — there the composer is
already closed and the toast's Undo SHOULD bring it back.

Also: chromium-exclusive now actually runs on one worker, which its own comment
has claimed since it was written. Without the override it inherited four, and
`fullyParallel: false` means parallel across FILES — so every spec in a project
that exists because its members own install-wide state was running concurrently
with the others. It held by luck until two of them wanted the same singleton.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:16:36 +02:00
Paul LütznerandClaude Opus 5 377d6026b8 feat(ai): make the language model observable, and the composer honest
"I click the AI button in the composer and nothing happens" turned out not to be
about the model at all. #editor() queried data-compose--toolbar-target while the
composer renders data-compose--compose-toolbar-target, so run() returned at its
first guard: no request, no status, no error. The feature had never worked once.
#subject() was wrong the same way and quieter — every prompt went out with an
empty subject. And the status element lived INSIDE the dropdown that the same
click closes, so even once working it would have written into a hidden subtree.

The rest of this is what makes the next such thing findable.

RECORDING
One row per model call, tagged by the workload that caused it, and nothing about
what was in it — no prompt, no completion, no message id, no subject, no
address. error_kind is a category from a closed set for the same reason: an HTTP
client's exception message routinely quotes the request body back, and here that
is somebody's mail. OllamaClient was discarding Ollama's whole timing block; it
now returns it, and AiAssistant — the only place that knows both the feature and
the model — writes the row.

DBAL and deliberately no entity, following EmbeddingStore: BackfillEmbeddings
clears the EntityManager after every chunk, so an unflushed ORM row would be
detached and silently dropped, fifty at a time, for as long as a backfill runs —
which is exactly the window the panel exists to show.

AiFeature could not carry this. A four-character search query somebody is
waiting on and two thousand characters of mail body written unattended a hundred
thousand times both went through embed() with no discriminator, and averaged
together the second buries the first — the panel would report backfill
throughput under the heading "search". AiCallFeature splits them.

THE PANEL
Live state from /api/ps, which is passive and safe to poll. Six states that look
different, and the one that justifies the work is "partly on the CPU":
size_vram < size means layers spilled, throughput collapses, and the model still
answers correctly — invisible everywhere else, so everything is simply slow
forever.

THE COMPOSER
Tokens stream into a provisional surface and commit in one operation, because
hundreds of per-token insertions destroy the undo stack and Ctrl+Z is the first
thing anyone tries. Waiting, generating, done and failed are distinct states
with reasons: outside the keep-alive window the first thirteen seconds produce
nothing while 20 GiB loads, and a featureless spinner for that IS the bug.
Cancellation propagates to the host — an abandoned draft otherwise holds the GPU
and everything else queues behind it.

SEARCH
Silent degradation to keyword-only is replaced by the reason it degraded, and
rows found by meaning say so. The semantic arm and its join are now matched on
model AND width: vectors from different models are not comparable, and after
somebody changes the search model every existing vector is dead weight. On a
pgvector install comparing different widths raises an error — a 500 on
/mail/search caused by an admin dropdown — and on the shipped loop it silently
compares the overlap, which is worse. Coverage is stated where results are shown,
because semantic search over an 8%-indexed mailbox is a trap.

BACKFILL
Progress is counted from the database, never accumulated, so a killed worker
resumes. It stands aside while anyone is using the AI and comes back on its own:
both compete for one integrated GPU, and a batch in flight when somebody presses
the button puts them straight back to "nothing happens".

Also carries the cron line for app:jobs:reap from the commit before this one.

Verified against the real host: reachable, both configured models installed,
timings parsed, 1024-dimension embeddings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 00:38:43 +02:00
Paul LütznerandClaude Opus 5 cbfca378f8 test(e2e): the suite reads the clock the page is rendered on
The v0.1.13 tag went red here, and none of it was the release.

WALL CLOCKS
Snooze times are computed in the BROWSER, deliberately — the server is
never told the session's zone — but the row is rendered server-side in
the user's zone, which falls back to Europe/Berlin. The spec then read
the hour back with Node's getHours(), which answers in the RUNNER's zone.

All three agree on a developer's machine, which is why this passed
eighteen consecutive local runs. On a GitHub runner the browser and Node
are UTC while the server is still Berlin: the mail came back at 18:00,
the row said 20:00, and the assertion reported the wrong TIME being shown
when the truth was the wrong CLOCK being read.

APP_TIMEZONE now names the zone rendered times are in, in one documented
place; the assertion converts into it rather than trusting the process.
And playwright.config pins the browser to it, so a menu that offers six
in the evening cannot produce a row that says eight — the divergence is
an artefact of an unpinned runner and no real user has it, since their
browser and their configured zone are the same place.

Reproduced before fixing: reverting the conversion under TZ=UTC fails
with the CI failure exactly.

TWO FIXTURES LEFT TO CHANCE, both found by running under TZ=UTC at CI's
two workers.

calendar.spec seeds calendars and never seeded a mailbox, but its pane
cases assert on the mail list beside the calendar. Those rows were
whatever the last spec on the worker left: bulk-select-all archives two
hundred messages and then clears them, after which `[data-row-layout]`
matches nothing and the failure reads as a broken container query when
there is simply no row to lay out.

admin-panels assumed the Processes panel starts open. Collapse is stored
per admin user, the admin is shared rather than per worker, and a run
that fails anywhere after the first toggle leaves it collapsed — so the
next run failed on its opening assertion, reporting a broken feature and
really reporting its own earlier failure. What collapsed it this time is
not established; the test establishes its own precondition now, which is
the part that was missing either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 17:06:29 +02:00
Paul Lützner 0c3d690a91 fix(test): the e2e base URL has one definition, and the suite runs off a clean checkout
preview-pane.spec.ts builds its own browser context — the wizard step needs a
separate user and a separate session — and handed it `process.env.E2E_BASE_URL`
raw, without the fallback playwright.config.ts applies. Unset, that is
`undefined`, and every case in the file died on "Cannot navigate to invalid
URL". Which is to say: `npx playwright test` on a stock checkout failed there
and nowhere else, while every other spec ran fine off the default, so the
failure read as a broken test rather than a missing variable.

BASE_URL now lives in tests/e2e/support/config.ts and both the config and the
spec import it. `use.baseURL` only reaches contexts Playwright makes itself, so
any spec building its own has to be handed the value — and now there is one
value to hand it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 1a6d8223403c904ad1e0654e97c6a8cc9fd12f80)
(cherry picked from commit 29ac828383ef69aecea484f69b9642d260ffcc45)
(cherry picked from commit 4aae6da96e2b8395931b40766fd3cdfde064a0c0)
2026-08-17 11:25:39 +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 Fable 5 eab8d650dd ci(e2e): the dev server restarts when it dies
php -S segfaulted once mid-suite (v0.0.25) and every later spec failed as
ERR_CONNECTION_REFUSED. A crash now costs the requests in flight; their retry
finds a listening socket. The log is appended, so the line it died after stays
readable above the restart marker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 15:50:58 +02:00
Paul LütznerandClaude Opus 5 085c4130aa Serve the browser suite from PHP, not from the Symfony CLI
103 Playwright tests failed on CI and none of them were broken. The
Symfony CLI detects running Docker containers and injects its own
environment into the app it serves, overriding DATABASE_URL even when one
is explicitly set. On CI the Postgres service is a container, so the URL
the workflow sets was replaced with credentials nothing accepts, the app
could authenticate nobody, the per-worker login fixture died, and every
test in that worker fell over behind it. PHPUnit and every bin/console
step passed in the same job, because none of them go through the CLI.

    DATABASE_URL=postgresql://app:app@127.0.0.1:5432/app \
      symfony php -r 'echo getenv("DATABASE_URL");'

SYMFONY_SKIP_DOCKER_COMPOSE, SYMFONY_SKIP_DOCKER_SERVICES and
SYMFONY_DOCKER_ENV were all tried; none of them stop it, and there is no
--no-docker flag. So the suite now starts PHP's own server, which has no
detection to disable. The CLI install goes with it: it was there for
`symfony serve` alone.

Two things that server needs and does not do by itself. Naming
public/index.php as the router makes index.php answer everything,
including the 56 compiled assets /login pulls in — each one a kernel boot
returning 404 — so tests/e2e/support/router.php returns false for real
files and lets the server serve them off disk. And the cli-server SAPI
builds $_SERVER from the HTTP request and stops: getenv() answers,
$_SERVER does not, and Dotenv reads $_SERVER. Without the bridge in that
router, DATABASE_URL would silently revert to the committed default in
.env — the same failure as above arriving by a different road. HTTP_* is
excluded from the bridge, because $_SERVER['HTTP_PROXY'] is
indistinguishable from a Proxy header once a Request is built.

The server's log is redirected to a file and uploaded with the report
rather than piped to the console: it writes three lines per request, and
one slice of the suite produced 67,709 of them, which is how a real
failure gets buried. It is still the only place a PHP fatal that never
reached the kernel is recorded, so it is kept rather than silenced.

One onboarding test now retries opening the user menu. The menu is opened
by a Stimulus controller, and Playwright's actionability checks say
nothing about whether a controller has connected — the button is visible
in the server's HTML long before the module graph that animates it
arrives, and a click that lands in that gap is simply lost. FrankenPHP
served those 56 assets fast enough to hide it; this server does not.

Verified against a CI-shaped stack — host PHP, Postgres in a container,
the workflow's env block, this config starting the server: 185 passed,
14 skipped, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:46:08 +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 d0685d0ee6 update 2026-07-28 12:33:46 +02:00
Paul Lützner 517bd5d345 testing 2026-07-21 16:18:46 +02:00