Merge pull request #527 from trails-cool/e2e-hydration-flake
e2e: close the interact-before-hydration race (cold-start flake)
This commit is contained in:
commit
7092ea42ff
5 changed files with 48 additions and 38 deletions
|
|
@ -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";
|
import { setupVirtualAuthenticator, registerUser } from "./helpers/auth";
|
||||||
|
import { setProfileVisibility } from "./helpers/profile";
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe.configure({ mode: "serial" });
|
test.describe.configure({ mode: "serial" });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { expect };
|
||||||
export type { CDPSession, Page } from "@playwright/test";
|
export type { CDPSession, Page } from "@playwright/test";
|
||||||
|
|
|
||||||
21
e2e/helpers/profile.ts
Normal file
21
e2e/helpers/profile.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -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";
|
import { setupVirtualAuthenticator, registerUser } from "./helpers/auth";
|
||||||
|
import { setProfileVisibility } from "./helpers/profile";
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe.configure({ mode: "serial" });
|
test.describe.configure({ mode: "serial" });
|
||||||
|
|
||||||
|
|
@ -39,8 +30,7 @@ test.describe("Notifications", () => {
|
||||||
await setProfileVisibility(bPage, "public");
|
await setProfileVisibility(bPage, "public");
|
||||||
|
|
||||||
// A follows B (auto-accept).
|
// A follows B (auto-accept).
|
||||||
await page.goto(`/users/${bUsername}`);
|
await gotoHydrated(page, `/users/${bUsername}`);
|
||||||
await waitForHydration(page);
|
|
||||||
await page.getByRole("button", { name: "Follow" }).click();
|
await page.getByRole("button", { name: "Follow" }).click();
|
||||||
await expect(page.getByRole("button", { name: "Unfollow" })).toBeVisible({ timeout: 5000 });
|
await expect(page.getByRole("button", { name: "Unfollow" })).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
|
@ -76,7 +66,7 @@ test.describe("Notifications", () => {
|
||||||
// B stays private (default).
|
// B stays private (default).
|
||||||
|
|
||||||
// A requests to follow B.
|
// 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 page.getByRole("button", { name: /Request to follow/i }).click();
|
||||||
await expect(page.getByRole("button", { name: /Requested/i })).toBeVisible({ timeout: 5000 });
|
await expect(page.getByRole("button", { name: /Requested/i })).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
import { setupVirtualAuthenticator, registerUser } from "./helpers/auth";
|
||||||
|
import { setProfileVisibility } from "./helpers/profile";
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebAuthn + parallel workers + shared local Postgres race; serialize.
|
// WebAuthn + parallel workers + shared local Postgres race; serialize.
|
||||||
test.describe.configure({ mode: "serial" });
|
test.describe.configure({ mode: "serial" });
|
||||||
|
|
@ -71,7 +61,7 @@ test.describe("Social follows + /feed", () => {
|
||||||
await setProfileVisibility(bPage, "public");
|
await setProfileVisibility(bPage, "public");
|
||||||
|
|
||||||
// A visits B's profile and follows.
|
// 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 expect(page.getByRole("button", { name: "Follow" })).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Follow" }).click();
|
await page.getByRole("button", { name: "Follow" }).click();
|
||||||
await expect(page.getByRole("button", { name: "Unfollow" })).toBeVisible({ timeout: 5000 });
|
await expect(page.getByRole("button", { name: "Unfollow" })).toBeVisible({ timeout: 5000 });
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue