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>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-08-23 17:06:29 +02:00
1 parent d7b2334614
commit cbfca378f8
5 files changed
+80 -3

No files matched your search

+17 -1
View File
@@ -1,5 +1,5 @@
import { defineConfig, devices } from "@playwright/test";
import { BASE_URL } from "./tests/e2e/support/config";
import { APP_TIMEZONE, BASE_URL } from "./tests/e2e/support/config";
/**
* plMail end-to-end configuration.
@@ -77,6 +77,22 @@ export default defineConfig({
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
+13
View File
@@ -46,6 +46,19 @@ test.describe("admin panels", () => {
await page.goto("/admin?section=system");
await expect(panel(page, "processes")).toBeVisible();
// Put the panel where this test needs to start, rather than trusting it
// to be there. Collapse is stored PER ADMIN USER on the server and the
// admin is shared, not per worker, so the starting state is whatever the
// last run of this test left behind — and a run that fails anywhere
// after the first toggle leaves it collapsed. The next run then failed
// on its opening assertion, reporting a broken feature and really
// reporting its own previous failure. One red run became a red run
// every time until somebody clicked it open by hand.
if (null === await panel(page, "processes").getAttribute("open")) {
await toggle(page, "processes");
}
await expect(panel(page, "processes")).toHaveAttribute("open", "");
await toggle(page, "processes");
+16
View File
@@ -546,6 +546,22 @@ test.describe("a meeting on two calendars", () => {
});
test.describe("calendar pane", () => {
/**
* The mail this describe reads, seeded rather than inherited.
*
* Several cases here assert on the LIST beside the calendar — that its rows
* stack when the pane takes the width, that the mail is still there behind
* a full-width pane. This file seeds calendars and never seeded a mailbox,
* so those rows were whatever the previous spec on this worker happened to
* leave: bulk-select-all archives two hundred messages and then clears
* them, and after it the inbox is empty. `[data-row-layout]` then matches
* nothing and the failure reads as the container query being broken, when
* there is simply no row to lay out.
*/
test.beforeEach(() => {
seed("seed-mail");
});
/**
* The switch has three positions — mail, split, calendar — and cycles
* through them in that order, wrapping. Which one it is in is a stored user
+19 -2
View File
@@ -1,5 +1,5 @@
import { test, expect } from "./support/test";
import { INBOX_SUBJECTS, mailRow, seed } from "./support/config";
import { APP_TIMEZONE, INBOX_SUBJECTS, mailRow, seed } from "./support/config";
import { rowAction } from "./support/rows";
/**
@@ -86,7 +86,24 @@ test("a snoozed conversation shows its wake time and can be woken", async ({ pag
// arrival time too and would pass with this fix reverted. Both clock
// formats are accepted: which one renders is a user setting, and the claim
// here is about WHICH TIME is shown rather than how it is written.
const hour = new Date(wakeAt).getHours();
// Read in the zone the PAGE renders in, not the one Node is running in.
//
// The row is formatted server-side in the user's zone, which falls back to
// Europe/Berlin; `getHours()` answers in the runner's. Those are the same
// thing on a developer's machine and two hours apart on a GitHub runner, so
// this passed here and failed only on the tag: the mail came back at 18:00,
// the row said 20:00, and the assertion reported the wrong TIME being shown
// rather than the wrong CLOCK being read.
//
// See APP_TIMEZONE, and compose-scheduled-send's wallClockIn, which is the
// same lesson learned in the same place.
const hour = Number(
new Intl.DateTimeFormat("en-GB", {
timeZone: APP_TIMEZONE,
hour: "2-digit",
hour12: false,
}).format(new Date(wakeAt)),
) % 24;
const shown = await row.innerText();
// `0?` because the hour is zero-padded on screen and not in a Date: at
+15
View File
@@ -59,6 +59,21 @@ const SLOT = WORKER_SLOT;
* A side effect worth having: the suite becomes re-runnable against a warm
* database, because nothing depends on a fixed address whose rows accumulate.
*/
/**
* The zone the SERVER renders wall-clock times in.
*
* Mirrors `app.fallback_timezone` in config/services.yaml. The seeded users
* leave `User::$timezone` null, so every time on a rendered page is formatted in
* this zone regardless of where the browser or the runner think they are.
*
* A test that reads an hour off a page must therefore convert into THIS zone,
* never with Node's `new Date().getHours()` — that is the runner's zone, which
* is Europe/Berlin on a developer's machine and UTC on a GitHub runner. Tests
* that get it wrong pass locally and fail only in CI, two hours out, looking
* like a timing flake.
*/
export const APP_TIMEZONE = "Europe/Berlin";
export const TEST_USER = {
email: `e2e-w${SLOT}@plmail.test`,
password: process.env.APP_DEV_USER_PASSWORD ?? "e2e-password-change-me",