* Bump @playwright/test
Bumps the playwright group with 1 update in the / directory: [@playwright/test](https://github.com/microsoft/playwright).
Updates `@playwright/test` from 1.63.0 to 1.64.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.63.0...v1.64.0)
---
updated-dependencies:
- dependency-name: "@playwright/test"
dependency-version: 1.64.0
dependency-type: direct:development
update-type: version-update:semver-minor
dependency-group: playwright
...
Signed-off-by: dependabot[bot] <support@github.com>
* E2E: abort the stubbed reconnect navigation instead of answering 204
Chromium 156 (Playwright 1.64) reports a navigation that ends in a 204 as a
finished request that commits nothing, where Chromium 155 reported it as
failed (net::ERR_ABORTED). Playwright's click waits for the navigation it
started to commit or fail, so with 1.64 the click never returned and the
test timed out reading "" off a closing page. An abort is reported as a
failure by every Chromium and keeps the document just the same.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* E2E: touch beside the open drawer, after it has stopped sliding
The drawer-open pull touched the middle of the rows (x~196) right after
`inert` came off, at the start of the drawer's 200ms slide. Once the 256px
drawer has arrived, that point is the drawer, not the backdrop, so the
hit-test only passed while it beat the transition; slower CI runners lost
that race on every branch from 2026-10-16. Wait for the drawer to rest,
then touch the backdrop strip to its right, and pull at the same point
once it is closed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Paul Lützner <karatektus@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
v0.2.25 moved whole-view bulk actions onto a queue and gave the browser stack
a consumer for it, and then left every OTHER way of bringing that stack up
naming only `app`. `up <service>` starts that service and its DEPENDENCIES; a
worker consuming a queue the app writes to is a dependent, and compose has no
way to know it matters. So CI queued a whole-view archive to a real Postgres
transport with nothing to pick it up, and bulk-select-all.spec.ts waited
fifteen seconds for rows that were never going to move.
Reproduced before fixing, by stopping the worker and running the spec: the
same two tests, in the same file. With the worker named, three pass in seven
seconds.
The same line existed in three places — the CI composite action, the
test:env:up script and the handbook's worktree recipe — and all three now name
it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MERCURE 0.5.0 / symfony/mercure 0.8.0, kept out of the earlier sweep
because it carries the live-update transport and should not ride along
with eighteen patch bumps. It earned that: HubInterface gained
getProtocolVersion() and getCookieName(), so the two hub doubles in
CalendarSyncFinishedNotificationTest stopped satisfying it — a fatal
error, not a failing test. Both implement them now, returning what a real
hub would rather than pretending to configuration these tests do not
have. Verified where it matters: the full browser suite, including the
specs that stop and restart the hub to prove the stream recovers.
A DATABASE OF ITS OWN for the unit suite.
Both suites run APP_ENV=test, and doctrine.yaml's
`dbname_suffix: '_test%env(default::TEST_TOKEN)%'` therefore pointed both
at app_test. One database, two suites — and one test in here truncates
real tables, because exercising a data reset is its whole purpose.
Sequentially that is safe; Postgres rolls a TRUNCATE back like anything
else. Concurrently it is not: TRUNCATE takes an ACCESS EXCLUSIVE lock, so
live queries from the browser suite block until the unit test's
transaction ends, and specs with no connection to any of this report
timeouts. It fits the transaction abort seen here once and never
reproduced.
TEST_TOKEN is Symfony's own mechanism for exactly this, so the fix uses
it rather than inventing a second DATABASE_URL. bin/test-unit.sh sets it
and does the create/migrate/seed a fresh database needs.
The seeding is the part worth guarding: PageRendersTest, ListFragmentTest
and others SKIP THEMSELVES when the admin user is missing, so without it a
green run would quietly be ~50 tests smaller than it looks — a worse
failure than a red one. Checked rather than assumed: 4047 tests either
way, no skipped or incomplete markers.
The assertion count moved, 11540 → 11526, and settled there across runs.
That is the point of the change showing up: the unit suite was reading
rows the browser stack had left behind, and now it reads only its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pin was wrong and so was the reason I gave for it.
I claimed 1.62 had changed what counts as visible, on the strength of
measuring the element under 1.61 and finding it painted. Probed under
1.62 the same element reports isVisible() TRUE. The geometry told me the
element was fine; I turned that into a claim about the tool.
The actual failure, from the log:
locator.check: Clicking the checkbox did not change its state
A forced click on an `sr-only` radio — a 1px clipped box — that lands
without taking. label-bulk-toggle.spec.ts documents this exact sentence
and works around it, noting it hit "roughly one full run in three". So it
was already broken under 1.61 and merely intermittent; 1.62 made it
reliable. Eight failures at once instead of one every third run is the
tool exposing a broken test, not breaking a working one.
The fix was one helper away in the same file. pick() has always clicked
the LABEL, for the reason its comment gives — the radio is invisible and
the label is what a person clicks. setToggle() never caught up. It does
now, waits for the write it triggers, and asserts the radio actually
moved.
The last straggler was self-inflicted: the corner is a stored preference
restored by afterEach, which used the same broken helper, so my failing
runs left it switched off and the next run's "on by default" assertion
was reading their residue.
578 pass on 1.62.1. The other seven check({ force: true }) sites are
untouched — none of them failed, and rewriting them on suspicion is how
tests acquire workarounds nobody can later justify.
v0.1.16 shipped with the wrong explanation in its notes; the next release
carries the correction rather than a rewritten tag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five of the six open Dependabot PRs, taken locally in one pass rather
than merged one at a time: each rebase triggers the expensive workflow
and they all touch the same two lock files. The sixth, the actions group,
was merged as it was — its own CI was green, and it clears the Node 20
deprecation the runner has been printing.
symfony/* 8.1.0/8.1.1 → 8.1.5 (18 packages)
symfonycasts/tailwind-bundle 0.14.0 → 1.0.0
@types/node 22 → 26.2.0
typescript 5.6 → 7.0.2
PLAYWRIGHT IS PINNED AT ~1.61.1, deliberately, and this is the only part
worth arguing about.
1.62 failed eight cases in appearance/list-display.spec.ts, every one of
them asserting that a clip-path'd element is visible. Tailwind was the
obvious suspect and is innocent: the compiled clip-path and spacing rules
are byte-identical between the two builds. Reverting Playwright alone
took it from eight failures to none.
Then the question that actually matters — whether 1.62 is RIGHT, because
"element is hidden" could mean a real UI bug. It is not. The account
corner measures 10×10 at (957, 336), display block, visibility visible,
opacity 1, an opaque background, and a parent that does not clip. It is
on screen and painted. 1.62 changed what it counts as visible for
clipped elements and is wrong about this one.
`~1.61.1` rather than `^`: a caret would walk straight back into 1.62 on
the next clean install.
TypeScript 7 earned its place. It found a genuine error — el.click() on
`HTMLElement | SVGElement`, in a test written this morning, since TS7's
lib.dom no longer puts click() on SVGElement — plus nine implicit-anys
that TS5 had also been reporting into a void, because CI does not run
typecheck. All eleven fixed: `npm run typecheck` is clean for the first
time. A typecheck that is permanently red is one nobody runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CODE-REVIEW.md S-01, critical. Message carries two body fields and the
difference is the whole security boundary: bodyHtml is the sender's raw HTML,
bodyHtmlSafe is what MailBodySanitizer made of it. The read path uses the safe
one and renders it in a sandboxed frame. ReplyDraftBuilder::quote() reached for
the raw one, and the draft it builds is written into the app's own document by
the compose window's `|raw`, into a contenteditable, outside every protection
the read path has.
So a mail containing <img src=x onerror=…> was inert while it was read and ran
the moment the recipient pressed Reply — in a session that can read the
mailbox, mint an API token that survives a password change, and add a
forwarding rule. Reply, reply-all and forward all reach quote() separately.
Three changes, and the second is the one that closes the class rather than the
instance:
1. quote() sanitises. sanitizeFragment() rather than the already-clean
bodyHtmlSafe, whose cid: references are rewritten into app URLs and would
need converting back on the way out.
2. DraftPersister::markAsDraft() sanitises bodyHtml itself, before sanitize()
derives bodyHtmlSafe from it. sanitize() only ever wrote the safe copy — it
never touched the field it read — so a draft could be sanitised and still
hold script in the field that is echoed back AND put on the wire. Every
caller of markAsDraft is covered, including JmapDraftWriter, which the
review flagged as a drifted copy.
3. A compose profile on the sanitiser for (2), because the mail allow-list
drops every data- attribute. Correct for a stranger's HTML, quietly
destructive for a composed body: data-quoted is how the quote is found to
collapse it and to cut it off the snippet, data-pl-signature is what a
changed From replaces, data-cid is the bridge to the cid: reference that
goes out. Sanitising without them would have left the body safe and the
composer broken.
Two traps found while writing (3), both caught by existing tests:
HtmlSanitizerConfig is immutable and every allow* method answers a clone, so
`$config->allowAttribute(...)` called for effect configures a copy that is then
dropped — it looks like a working allow-list and strips everything it was meant
to keep.
The sanitiser escapes @ to @ in attribute values, which is correct HTML and
fatal for a cid: reference: Symfony's Email::prepareParts() pairs an embedded
part to the body by matching the literal string cid:<name>, as does
InlineAttachmentDetector at ingest. Neither parses HTML, so every mail sent
with an inline image would have gone out with a broken one. restoreCidReferences
puts those back, and only where the decoded reference still looks like one —
decoding an attribute value in general is a way back out of it.
Also: test:unit:docker now brings the stack up like the other :docker: scripts.
It is `run --rm --no-deps`, so against a stopped stack it blocked in poll() on a
dead socket with no timeout and no output — CONTRIBUTING.md:107 has claimed it
starts the stack all along.
Verified: full unit suite green, Playwright green on everything this touches
(the inline-image spec is what caught the @ problem), PHPStan clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
**The picker was not the bug; the navigation was.** Every sidebar link on
a German page was built against the English path, so choosing German
lasted exactly one click and then put you back in English. The sidebar
and the Handbook link now follow the page's own language.
**And the choice survives arriving somewhere else.** It is remembered, so
a bookmark, a link from somebody else or a fresh visit lands in the
language you picked rather than resetting to English every time. The
redirect cannot loop — it lands where the page's language already equals
the remembered one — and it cannot strand anybody, because choosing
English is what sets the memory to English. The landing page is exempt:
following the memory there turned every visit to the site into a redirect
into the handbook, which is a redirect away from the page somebody asked
for rather than a language preference.
tests/e2e/site.spec.ts covers all of it — the two bugs you found by
clicking, plus the three ways the fix could go wrong. It is opt-in behind
E2E_SITE (`npm run test:e2e:site`) and does not run in CI: it needs a PHP
build and eight browser pages, and what it guards is a static site whose
failure is visible the moment anybody opens it.
**Publishing is faster.** The Pages job ran composer.json's auto-scripts
on every docs change — cache:clear, assets:install and importmap:install,
which is a Symfony kernel boot and a set of CDN downloads for JavaScript,
to render Markdown. --no-scripts, plus a download cache. The wiki job
needs no Composer at all and never did: bin/mirror-wiki.php has no
dependencies, which is now said out loud so nobody adds one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An invitation you were sent creates an event on the account's calendar;
the same meeting on the connected calendar arrives with the organiser's
UID and a remote id of its own. CalendarPuller already falls back from
remote id to uid — but scoped to one calendar, and these are two — so
both rows exist and the meeting is on screen twice.
Both rows are right and both stay. They are two remote objects with
their own id, etag and sync state, and collapsing them in the model
would break sync. The duplication is real in the data and false on the
screen, so it is fixed on the screen: occurrences are grouped on uid
plus start into a cluster, and a cluster of one is still a cluster, so
the three views keep one code path.
Merging is only silent while the copies agree — start, end, title,
all-day, called-off. The moment they disagree the whole group splits
back into separate chips, and the whole group rather than the minority,
because keeping the majority is picking a winner with extra steps. A
merged chip that quietly chose one would hide an update that reached one
path and not the other, which is precisely the failure this was meant
to surface.
The editor gains a checkbox per copy and loses the calendar dropdown
while it has them. Both controls answer "which calendars is this on?"
with different cardinalities, and every way of honouring both is bad:
moving all copies onto one collapses N remote objects and violates
uniq_calendar_event_calendar_uid, moving only the primary gives the
control a silent third meaning. A read-only copy is listed, disabled and
unticked, and filtered again server-side — a disabled checkbox is a
statement to a browser, not a guarantee to a server.
A per-instance edit across a cluster is implemented rather than refused.
The merged chip is the only chip that occurrence has, so refusing would
make "move next Tuesday's standup" impossible without hiding a calendar
in settings. It is the same patch filed once per copy, resolved against
each copy's own occurrence at that recurrence id.
Two smaller things fell out. Merging happens before the day walk, not
after: a cluster spanning midnight would otherwise be drawn twice on the
day only one member's row touched. And members must be on distinct
calendars — a uid is unique per calendar, so a repeat within a group is
one series with an instance dragged onto a sibling's time, and merging
those erases an occurrence from the view.
The e2e scripts grew :docker:headed and :docker:trace. The existing
:headed and :ui do not set E2E_DOCKER, so they start a second app under
symfony serve rather than using the stack already running — which needs
the Symfony CLI and puts the app on another port.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Playwright collects V8 coverage when E2E_JS_COVERAGE is set, one raw file
per test, aggregated by bin/js-coverage.mjs. Only this application's own
scripts are counted — Stimulus and Turbo are in the same importmap and
would otherwise dominate a number nobody here can act on.
The aggregation is the part worth reading. V8 nests its ranges: a
function that ran reports one span with a count, and the branches inside
it that did not as zero-count spans within it. So the covered set has to
be resolved per test and only then unioned. Pooling the dead ranges
across tests instead reads "this block did not run in test A" as "this
block never runs" — and since the importmap preloads every controller on
every page, every spec that merely loaded a module cancelled the specs
that exercised it. It reported 28.2% and did not move when three specs
were added, which is what gave it away. Corrected: 63.1%.
Three new spec files, for three controllers with no browser test at all:
search the operator dropdown and what the parser does with an
operator it cannot honour — the difference between "no
results" and "your whole mailbox". 22% to 90%.
admin-queue what a worker is holding, and that the filter asks the
server rather than sifting the page already fetched.
0% to 79%, with app:test:seed-queue to give the panel
something to be about.
filters-scope removing the last condition, and the sentence naming the
account once one is chosen. 18% to 77%.
One bug, found the way browser tests are supposed to find them: the
search box is an <input type="search">, which Chromium clears natively
on Escape — so "first Escape closes the list, second clears the box" had
never worked. The close branch preventDefaults now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>