From b7ed54ba723a1b1062257ab9b52477854ebe29a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 8 May 2026 01:06:27 +0200 Subject: [PATCH] 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$`) 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) --- e2e/auth.test.ts | 4 +++- e2e/explore.test.ts | 3 ++- e2e/fixtures/test.ts | 24 ++++++++++++++++++++++++ e2e/notifications.test.ts | 4 +++- e2e/public-content.test.ts | 3 ++- e2e/social.test.ts | 3 ++- playwright.config.ts | 6 +++++- 7 files changed, 41 insertions(+), 6 deletions(-) diff --git a/e2e/auth.test.ts b/e2e/auth.test.ts index 821a3fd..0542307 100644 --- a/e2e/auth.test.ts +++ b/e2e/auth.test.ts @@ -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 }); diff --git a/e2e/explore.test.ts b/e2e/explore.test.ts index 7bd442a..defa731 100644 --- a/e2e/explore.test.ts +++ b/e2e/explore.test.ts @@ -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(); diff --git a/e2e/fixtures/test.ts b/e2e/fixtures/test.ts index 8f27f1b..0309d4f 100644 --- a/e2e/fixtures/test.ts +++ b/e2e/fixtures/test.ts @@ -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$` 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"; diff --git a/e2e/notifications.test.ts b/e2e/notifications.test.ts index 39aa17e..e7549a9 100644 --- a/e2e/notifications.test.ts +++ b/e2e/notifications.test.ts @@ -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 }); diff --git a/e2e/public-content.test.ts b/e2e/public-content.test.ts index f0941f7..449633b 100644 --- a/e2e/public-content.test.ts +++ b/e2e/public-content.test.ts @@ -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(); diff --git a/e2e/social.test.ts b/e2e/social.test.ts index 270cf0b..b18587f 100644 --- a/e2e/social.test.ts +++ b/e2e/social.test.ts @@ -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(); diff --git a/playwright.config.ts b/playwright.config.ts index 6e21e1b..8acd9d3 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,7 +5,11 @@ export default defineConfig({ outputDir: "./e2e/results", fullyParallel: true, retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, + // Locally the journal/planner run under Vite dev — many parallel workers + // overwhelm cold-compile of /api/* routes and cause flaky timeouts on + // first hits. CI runs production builds and is unaffected. Keep at 1 in + // both environments for parity. + workers: 1, reporter: process.env.CI ? [["github"], ["html", { open: "never" }], ["json", { outputFile: "playwright-results.json" }]] : "list",