Files
pl_mail/playwright.config.ts
T
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

174 lines
9.1 KiB
TypeScript

import { defineConfig, devices } from "@playwright/test";
import { APP_TIMEZONE, BASE_URL } from "./tests/e2e/support/config";
/**
* plMail end-to-end configuration.
*
* The suite runs against the compose.test.yaml stack — the app's own FrankenPHP
* image, which is what production runs and what CI runs:
*
* npm run test:e2e:docker — brings that stack up, then runs the suite
* npm run test:e2e — runs the suite against a stack already up
*
* There is no second serving path any more. This config used to be able to
* start a `php -S` dev server for CI, through a router script in
* tests/e2e/support/, and every accommodation that server needed — a router to
* stop index.php answering asset requests, a copy of the environment into
* $_SERVER because the cli-server SAPI does not provide one, a php.ini the
* runner had to be given by hand, and finally a restart loop because the thing
* segfaulted mid-suite — was an accommodation for a server no user will ever
* meet. CI now boots the same stack this file points at, so the suite proves
* something about the app as shipped. See .github/workflows/e2e.yml.
*
* Every worker owns a dedicated user and signs in once for itself, through a
* worker-scoped fixture in tests/e2e/support/test.ts. That is why specs import
* `test` from there rather than from @playwright/test, and why there is no
* `setup` project any more — one shared storage-state file cannot serve
* workers that are logged in as different people.
*/
// How to reach the stack, resolved once, here.
//
// Some specs have to reach past the browser: seeding fixtures through the
// console, and — in mercure.spec.ts — stopping the hub to prove the stream
// comes back. Both go through these, so pointing the suite at a different
// stack is one variable rather than a hunt through the specs.
//
// The `-p` case is why this is shared rather than repeated. compose.test.yaml
// pins `name: pl_mail_test`, so a hardcoded `docker compose -f
// compose.test.yaml` is right exactly until somebody runs a second stack under
// another project name — a worktree, a second port — at which point it talks
// to the wrong project or to none at all. `stop` against a project that does
// not exist exits 0, so mercure.spec.ts spent that case silently not stopping
// the hub and then failing because the stream never dropped, and
// admin-queue.spec.ts seeded a database the browser was not looking at.
process.env.E2E_COMPOSE ??= "docker compose -f compose.test.yaml";
process.env.E2E_CONSOLE ??= `${process.env.E2E_COMPOSE} exec -T app php bin/console`;
// Storage state is per worker now and lives in tests/e2e/support/test.ts —
// there is no single path for the config to name.
export default defineConfig({
testDir: "./tests/e2e",
// Parallel across FILES, serial within one. Deliberately not fullyParallel:
// several specs depend on the order of tests inside their own file — the
// integration describes build on each other, twofactor enrols before it
// signs in — and file granularity preserves that for free, without any
// spec having to declare serial mode. It also means one file sees one
// worker, so the per-worker user (tests/e2e/support/config.ts) is stable
// for the whole file.
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
// Was 1 everywhere the specs owned the database, because they all shared one
// user and `seed-mail` wipes the mailbox it seeds. Each worker now owns a
// separate user, so they no longer collide. CI gets 2 (ubuntu-latest is 2
// vCPU on private repos); locally 4, above which the single app container
// rather than the runner becomes the limit.
workers: process.env.CI ? 2 : 4,
reporter: process.env.CI
? [["github"], ["html", { open: "never" }]]
: [["list"], ["html", { open: "never" }]],
// A locator that will never match should fail in seconds, not stall for the
// 30s test budget. Costs a green run nothing; makes a red one far quicker to
// read.
expect: { timeout: 5_000 },
use: {
baseURL: BASE_URL,
// The browser keeps the same clock the server renders in.
//
// Times reach a page two ways: formatted server-side in the user's zone,
// which falls back to Europe/Berlin, and computed in the browser — the
// snooze and schedule menus work out "later today" and "tomorrow morning"
// locally, on purpose, because the server is never told the session's zone.
// With the browser on UTC those two disagree by the offset: the menu offers
// six in the evening, the row that comes back says eight.
//
// Unpinned this is the runner's zone, so it is Europe/Berlin on a
// developer's machine and UTC on a GitHub runner — which is the difference
// between a suite that passes locally and a tag that fails, two hours out,
// for reasons that look like flakiness. Pinning it also makes the suite
// describe the ordinary case, where a person's browser and their configured
// zone are the same place.
timezoneId: APP_TIMEZONE,
trace: "on-first-retry",
screenshot: "only-on-failure",
// Off, not "retain-on-failure": that still screencasts every test and then
// throws the recording away on the ~97 that pass. The trace above is the
// more useful artifact and costs nothing unless a test is retried. For a
// local failure, re-run the one spec with `--video on`.
video: "off",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
// integrations.spec.ts, mercure.spec.ts, ai-compose.spec.ts and
// thread-summary.spec.ts are handled separately below.
testIgnore: /(integrations|mercure|ai-compose|thread-summary)\.spec\.ts/,
// No `storageState` here, and no `setup` project: signing in is now a
// worker-scoped fixture in tests/e2e/support/test.ts, because the path
// has to differ per worker and project config is static.
},
{
// The specs per-worker users cannot isolate.
//
// integrations: IntegrationProviderConfig and MailProviderConfig are both
// unique on `provider` with no user column — genuinely install-wide
// state. Two workers touching it would race, and it reaches further than
// it looks: whether the onboarding wizard offers its integrations step
// depends on what an admin has configured globally, so this spec can
// change what onboarding.spec.ts sees.
//
// mercure: it stops and restarts the hub container to prove the stream
// recovers on its own. There is one hub for the whole stack, so running
// that alongside anything else takes live updates away from specs that
// did not ask for it — which is exactly how it first showed up, as an
// integrations failure that passed on its own.
//
// ai-compose, thread-summary: AiSettings is a singleton with no user
// column, the same
// shape of install-wide state as the two above. Switching writing help on
// does not stay inside the spec that asked for it — it puts an extra
// button in every composer in the suite, which is how it first showed up:
// compose-send-optimistic.spec.ts passed or failed depending on whether
// the AI menu happened to be present. Summaries are the same switch on a
// different surface: an offer beside the subject of every conversation of
// more than one message.
//
// One worker, and `dependencies` so it starts only once everything else
// has finished.
name: "chromium-exclusive",
use: { ...devices["Desktop Chrome"] },
// ONE worker, which the paragraph above has always claimed and this file
// never enforced. Without it the project inherits the global 4, and
// `fullyParallel: false` means parallel across FILES — so every spec in
// here, each of which is here precisely because it owns install-wide
// state, ran concurrently with the others.
//
// It held only by luck: integrations and mercure happen to grab different
// globals. ai-compose and thread-summary both write the AiSettings
// singleton, and the moment they shared a run one switched the AI off
// under the other — an inline composer with no "Help me write" button,
// failing in a file that passes alone.
workers: 1,
testMatch: /(integrations|mercure|ai-compose|thread-summary)\.spec\.ts/,
dependencies: ["chromium"],
},
],
// No `webServer`. The app is the compose stack, and this config never starts
// it — `npm run test:e2e:docker` does, and CI does, with the same command.
//
// Deliberate, and the reason the old one is worth remembering: a server this
// config started was a server nothing else in the project used, so it needed
// its own front controller, its own copy of the environment, its own php.ini
// and eventually its own crash-restart loop, and a green suite said nothing
// about the FrankenPHP the app actually ships on. Booting the stack outside
// Playwright also means one running app for the whole suite rather than a
// second one racing the first onto the same fixtures.
});