From a05c8e87a17ae35cc081a3ae1678000510c0a900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 11 Jun 2026 14:09:00 +0200 Subject: [PATCH] e2e: close the interact-before-hydration race (cold-start flake) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- e2e/explore.test.ts | 13 ++----------- e2e/fixtures/test.ts | 18 ++++++++++++++++++ e2e/helpers/profile.ts | 21 +++++++++++++++++++++ e2e/notifications.test.ts | 18 ++++-------------- e2e/social.test.ts | 16 +++------------- 5 files changed, 48 insertions(+), 38 deletions(-) create mode 100644 e2e/helpers/profile.ts diff --git a/e2e/explore.test.ts b/e2e/explore.test.ts index b2c0809..8f87dfa 100644 --- a/e2e/explore.test.ts +++ b/e2e/explore.test.ts @@ -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" }); diff --git a/e2e/fixtures/test.ts b/e2e/fixtures/test.ts index 0309d4f..ea8c550 100644 --- a/e2e/fixtures/test.ts +++ b/e2e/fixtures/test.ts @@ -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"; diff --git a/e2e/helpers/profile.ts b/e2e/helpers/profile.ts new file mode 100644 index 0000000..41ead75 --- /dev/null +++ b/e2e/helpers/profile.ts @@ -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 }); +} diff --git a/e2e/notifications.test.ts b/e2e/notifications.test.ts index b53a800..57017c1 100644 --- a/e2e/notifications.test.ts +++ b/e2e/notifications.test.ts @@ -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 }); diff --git a/e2e/social.test.ts b/e2e/social.test.ts index ab4006f..2460e74 100644 --- a/e2e/social.test.ts +++ b/e2e/social.test.ts @@ -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 });