Stabilize e2e against Vite dev hydration race

Two issues caused the same class of flake locally:

1. Default workers were CPU-count, but the journal/planner are served
   by Vite dev (not the production build CI uses). Cold-compiling
   `/api/auth/register` under N parallel hits produced 30s timeouts
   on a quarter of runs. Set workers to 1 in both environments for
   parity with CI.
2. Even sequentially, a button is clickable per Playwright's
   actionability check before React has hydrated its `onClick`. So
   the first click after a navigation could fire native form submit
   (or do nothing), which manifested as "URL never changed to /" or
   "menuitem Log Out never appeared". Add a `waitForHydration` helper
   that polls for React fibers (`__reactProps$<id>`) attached to a
   DOM node and call it after each cross-page navigation that ends
   in an interactive form or dropdown.

CI is unaffected (production builds hydrate fast and didn't expose
either bug), but the helper is harmless there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-05-08 01:06:27 +02:00
parent fc2d0a02d5
commit b7ed54ba72
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
7 changed files with 41 additions and 6 deletions

View file

@ -1,4 +1,4 @@
import { test, expect, type CDPSession, type Page } from "./fixtures/test";
import { test, expect, waitForHydration, type CDPSession, type Page } from "./fixtures/test";
// Virtual authenticator helpers
async function setupVirtualAuthenticator(cdp: CDPSession) {
@ -23,6 +23,7 @@ async function removeVirtualAuthenticator(cdp: CDPSession, authenticatorId: stri
async function registerUser(page: Page, email: string, username: string) {
await page.goto("/auth/register");
await expect(page.getByRole("heading", { name: "Register" })).toBeVisible();
await waitForHydration(page);
await page.getByLabel("Email").click();
await page.getByLabel("Email").fill(email);
await page.getByLabel("Username").click();
@ -39,6 +40,7 @@ async function logout(page: Page, accountLabel: string) {
// The account cluster lives inside an avatar dropdown now. Click the
// avatar (its aria-label is displayName || username), then click the
// Log Out menuitem inside the popup.
await waitForHydration(page);
await page.getByRole("navigation").getByRole("button", { name: accountLabel }).click();
await page.getByRole("menuitem", { name: "Log Out" }).click();
await expect(page.getByRole("navigation").getByRole("link", { name: "Sign In" })).toBeVisible({ timeout: 5000 });

View file

@ -1,4 +1,4 @@
import { test, expect, type CDPSession, type Page } from "./fixtures/test";
import { test, expect, waitForHydration, type CDPSession, type Page } from "./fixtures/test";
async function setupVirtualAuthenticator(cdp: CDPSession) {
await cdp.send("WebAuthn.enable");
@ -17,6 +17,7 @@ async function setupVirtualAuthenticator(cdp: CDPSession) {
async function registerUser(page: Page, email: string, username: string) {
await page.goto("/auth/register");
await expect(page.getByRole("heading", { name: "Register" })).toBeVisible();
await waitForHydration(page);
await page.getByLabel("Email").fill(email);
await page.getByLabel("Username").fill(username);
await page.getByRole("checkbox").check();

View file

@ -74,5 +74,29 @@ export const test = base.extend({
},
});
/**
* Wait until React has hydrated the current page. Vite dev injects scripts
* asynchronously, so a button/form is visible (and clickable per Playwright's
* actionability check) before its React `onClick`/`onSubmit` is wired up.
* Without this, the first click after navigation is a coin flip on a cold
* dev server controlled inputs ignore the keystrokes and submit handlers
* never run, so the form posts empty fields or natively GETs to itself.
*
* Detects hydration by checking for React's `__reactProps$<id>` property
* which is attached during commit. CI uses production builds where this
* isn't a problem, but the check is cheap and harmless there.
*/
export async function waitForHydration(page: import("@playwright/test").Page) {
await page.waitForFunction(
() => {
const el = document.body.querySelector("button, input, form, a");
if (!el) return false;
return Object.keys(el).some((k) => k.startsWith("__reactProps$"));
},
null,
{ timeout: 10000 },
);
}
export { expect };
export type { CDPSession, Page } from "@playwright/test";

View file

@ -1,4 +1,4 @@
import { test, expect, type CDPSession, type Page } from "./fixtures/test";
import { test, expect, waitForHydration, type CDPSession, type Page } from "./fixtures/test";
async function setupVirtualAuthenticator(cdp: CDPSession) {
await cdp.send("WebAuthn.enable");
@ -17,6 +17,7 @@ async function setupVirtualAuthenticator(cdp: CDPSession) {
async function registerUser(page: Page, email: string, username: string) {
await page.goto("/auth/register");
await expect(page.getByRole("heading", { name: "Register" })).toBeVisible();
await waitForHydration(page);
await page.getByLabel("Email").fill(email);
await page.getByLabel("Username").fill(username);
await page.getByRole("checkbox").check();
@ -63,6 +64,7 @@ test.describe("Notifications", () => {
// A follows B (auto-accept).
await page.goto(`/users/${bUsername}`);
await waitForHydration(page);
await page.getByRole("button", { name: "Follow" }).click();
await expect(page.getByRole("button", { name: "Unfollow" })).toBeVisible({ timeout: 5000 });

View file

@ -1,4 +1,4 @@
import { test, expect, type CDPSession, type Page } from "./fixtures/test";
import { test, expect, waitForHydration, type CDPSession, type Page } from "./fixtures/test";
// Reuses the virtual-authenticator + register helpers from auth.test.ts.
// Inlined rather than factored out to keep this file independently runnable.
@ -19,6 +19,7 @@ async function setupVirtualAuthenticator(cdp: CDPSession) {
async function registerUser(page: Page, email: string, username: string) {
await page.goto("/auth/register");
await expect(page.getByRole("heading", { name: "Register" })).toBeVisible();
await waitForHydration(page);
await page.getByLabel("Email").fill(email);
await page.getByLabel("Username").fill(username);
await page.getByRole("checkbox").check();

View file

@ -1,4 +1,4 @@
import { test, expect, type CDPSession, type Page } from "./fixtures/test";
import { test, expect, waitForHydration, type CDPSession, type Page } from "./fixtures/test";
// Inline virtual-authenticator + register helpers, mirroring the pattern
// in auth.test.ts / public-content.test.ts so this file runs standalone.
@ -19,6 +19,7 @@ async function setupVirtualAuthenticator(cdp: CDPSession) {
async function registerUser(page: Page, email: string, username: string) {
await page.goto("/auth/register");
await expect(page.getByRole("heading", { name: "Register" })).toBeVisible();
await waitForHydration(page);
await page.getByLabel("Email").fill(email);
await page.getByLabel("Username").fill(username);
await page.getByRole("checkbox").check();