e2e: close the interact-before-hydration race (cold-start flake)

Diagnosis (reproduced by looping cold-server runs): the recurring
"cold start" failures in the auth/follow specs were a hydration race,
not a Vite dep-reload (the server.warmup config already handles that —
no reload events in the logs). FollowButton is an onClick button, and
Playwright considers it actionable (visible + enabled) before React
attaches the handler on a freshly-navigated page. A click in that
window is dropped (or triggers a native form submit), so the expected
state change never happens — e.g. notifications.test.ts:59 clicked
"Request to follow" with no hydration wait and the button never
flipped to "Requested".

The existing waitForHydration guard was applied per-interaction and so
was easy to forget (test 1 had it, test 3 didn't). Fix it at the
navigation instead:

- add gotoHydrated(page, url) = goto + waitForHydration, documented as
  the default for "navigate then interact with a React control"
- use it before every FollowButton interaction in notifications +
  social
- consolidate the setProfileVisibility helper (copy-pasted in three
  specs, two missing the hydration wait) into e2e/helpers/profile.ts,
  built on gotoHydrated

Validation against a fresh Postgres, cold servers: notifications 0/10
failures (was the proven flaker); notifications+social+explore 0/5
(55 tests); planner unaffected (25/25).

Note: two other "cold-start"-attributed flakes are NOT this race and
are out of scope here — planner map-load latency (mitigated by warmup
+ CI retries) and the /explore directory assertion (a local artifact
of the scratch DB accumulating >20 public users; CI's fresh DB stays
under the page size, so it doesn't bite there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-06-11 14:09:00 +02:00
parent f669969ad1
commit a05c8e87a1
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
5 changed files with 48 additions and 38 deletions

View file

@ -1,15 +1,6 @@
import { test, expect, waitForHydration, type CDPSession, type Page } from "./fixtures/test";
import { test, expect, type CDPSession } from "./fixtures/test";
import { setupVirtualAuthenticator, registerUser } from "./helpers/auth";
async function setProfileVisibility(page: Page, value: "public" | "private") {
await page.goto("/settings/profile");
// Wait for hydration so the visibility form submits via the fetcher
// (and shows the "Profile saved." toast) instead of native-navigating.
await waitForHydration(page);
await page.locator(`input[type=radio][name=profileVisibility][value=${value}]`).check();
await page.getByRole("button", { name: /^Save$/ }).first().click();
await expect(page.getByText("Profile saved.")).toBeVisible({ timeout: 10000 });
}
import { setProfileVisibility } from "./helpers/profile";
test.describe.configure({ mode: "serial" });

View file

@ -98,5 +98,23 @@ export async function waitForHydration(page: import("@playwright/test").Page) {
);
}
/**
* Navigate and wait for the page to be hydrated before returning.
*
* Use this (instead of bare `page.goto`) whenever the very next step
* interacts with a control whose behaviour lives in a React handler
* notably the onClick-driven FollowButton and the profile-settings
* fetcher form. A button is "visible and enabled" per Playwright's
* actionability check before React has attached its onClick, so a click
* in that window is silently dropped (or triggers a native form submit),
* which is the dominant source of the cold-server e2e flake. Waiting for
* hydration at the navigation closes that race so individual tests can't
* forget it per interaction.
*/
export async function gotoHydrated(page: import("@playwright/test").Page, url: string) {
await page.goto(url);
await waitForHydration(page);
}
export { expect };
export type { CDPSession, Page } from "@playwright/test";

21
e2e/helpers/profile.ts Normal file
View file

@ -0,0 +1,21 @@
import { expect, gotoHydrated, type Page } from "../fixtures/test";
/**
* Set the logged-in user's profile visibility via /settings/profile.
* Single source of truth this was copy-pasted across the explore,
* social, and notifications specs, two of which omitted the hydration
* wait and so raced the fetcher-backed Save on a cold server.
*/
export async function setProfileVisibility(page: Page, value: "public" | "private") {
// gotoHydrated so the visibility form submits via the fetcher (and
// shows the "Profile saved." toast) instead of native-navigating.
await gotoHydrated(page, "/settings/profile");
// Target the radio by name+value; getByLabel collides because one
// radio's help text mentions the other's word ("public" appears in
// the Private radio's helper sentence).
await page.locator(`input[type=radio][name=profileVisibility][value=${value}]`).check();
await page.getByRole("button", { name: /^Save$/ }).first().click();
// SSE keeps the network busy, so don't wait on networkidle — wait for
// the explicit save confirmation instead.
await expect(page.getByText("Profile saved.")).toBeVisible({ timeout: 10000 });
}

View file

@ -1,15 +1,6 @@
import { test, expect, waitForHydration, type CDPSession, type Page } from "./fixtures/test";
import { test, expect, gotoHydrated, type CDPSession } from "./fixtures/test";
import { setupVirtualAuthenticator, registerUser } from "./helpers/auth";
async function setProfileVisibility(page: Page, value: "public" | "private") {
await page.goto("/settings");
await page.locator(`input[type=radio][name=profileVisibility][value=${value}]`).check();
await page.getByRole("button", { name: /^Save$/ }).first().click();
// Don't use waitForLoadState("networkidle") — the SSE connection to
// /api/events keeps the network busy indefinitely. Wait for the
// explicit save confirmation instead.
await expect(page.getByText("Profile saved.")).toBeVisible({ timeout: 10000 });
}
import { setProfileVisibility } from "./helpers/profile";
test.describe.configure({ mode: "serial" });
@ -39,8 +30,7 @@ test.describe("Notifications", () => {
await setProfileVisibility(bPage, "public");
// A follows B (auto-accept).
await page.goto(`/users/${bUsername}`);
await waitForHydration(page);
await gotoHydrated(page, `/users/${bUsername}`);
await page.getByRole("button", { name: "Follow" }).click();
await expect(page.getByRole("button", { name: "Unfollow" })).toBeVisible({ timeout: 5000 });
@ -76,7 +66,7 @@ test.describe("Notifications", () => {
// B stays private (default).
// A requests to follow B.
await page.goto(`/users/${bUsername}`);
await gotoHydrated(page, `/users/${bUsername}`);
await page.getByRole("button", { name: /Request to follow/i }).click();
await expect(page.getByRole("button", { name: /Requested/i })).toBeVisible({ timeout: 5000 });

View file

@ -1,16 +1,6 @@
import { test, expect, waitForHydration, type CDPSession, type Page } from "./fixtures/test";
import { test, expect, gotoHydrated, type CDPSession } from "./fixtures/test";
import { setupVirtualAuthenticator, registerUser } from "./helpers/auth";
async function setProfileVisibility(page: Page, value: "public" | "private") {
await page.goto("/settings");
// Target the radio by name+value; getByLabel collides because the
// help text of one radio mentions the other's word ("public" appears
// in the Private radio's helper sentence).
await page.locator(`input[type=radio][name=profileVisibility][value=${value}]`).check();
await page.getByRole("button", { name: /^Save$/ }).first().click();
// SSE keeps the network busy; wait for the save confirmation instead.
await expect(page.getByText("Profile saved.")).toBeVisible({ timeout: 10000 });
}
import { setProfileVisibility } from "./helpers/profile";
// WebAuthn + parallel workers + shared local Postgres race; serialize.
test.describe.configure({ mode: "serial" });
@ -71,7 +61,7 @@ test.describe("Social follows + /feed", () => {
await setProfileVisibility(bPage, "public");
// A visits B's profile and follows.
await page.goto(`/users/${bUsername}`);
await gotoHydrated(page, `/users/${bUsername}`);
await expect(page.getByRole("button", { name: "Follow" })).toBeVisible();
await page.getByRole("button", { name: "Follow" }).click();
await expect(page.getByRole("button", { name: "Unfollow" })).toBeVisible({ timeout: 5000 });