trails/e2e/social.test.ts
Ullrich Schäfer a05c8e87a1
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>
2026-06-11 14:09:00 +02:00

146 lines
6.3 KiB
TypeScript

import { test, expect, gotoHydrated, type CDPSession } from "./fixtures/test";
import { setupVirtualAuthenticator, registerUser } from "./helpers/auth";
import { setProfileVisibility } from "./helpers/profile";
// WebAuthn + parallel workers + shared local Postgres race; serialize.
test.describe.configure({ mode: "serial" });
test.describe("Social follows + /feed", () => {
test("/feed redirects anonymous visitors to login", async ({ page }) => {
await page.goto("/feed");
await expect(page).toHaveURL(/\/auth\/login/, { timeout: 10000 });
});
test("/follows/outgoing redirects anonymous visitors to login", async ({ page }) => {
await page.goto("/follows/outgoing");
await expect(page).toHaveURL(/\/auth\/login/, { timeout: 10000 });
});
test("/follows/requests redirects to /notifications?tab=requests", async ({ page, browser }) => {
// The route was folded into the Notifications page as a tab. The
// 301 still resolves; for anonymous visitors the notifications
// loader then redirects to /auth/login.
await page.goto("/follows/requests");
await expect(page).toHaveURL(/\/auth\/login/, { timeout: 10000 });
// Signed-in visitors land on /notifications?tab=requests after the
// 301.
const cdp = await page.context().newCDPSession(page);
await setupVirtualAuthenticator(cdp);
const stamp = Date.now();
const ctx = await browser.newContext();
const p2 = await ctx.newPage();
const p2cdp = await p2.context().newCDPSession(p2);
await setupVirtualAuthenticator(p2cdp);
await registerUser(p2, `redir-${stamp}@example.com`, `redir${stamp}`);
await p2.goto("/follows/requests");
await expect(p2).toHaveURL(/\/notifications\?tab=requests/, { timeout: 10000 });
await ctx.close();
});
test("Follow button toggles state on a public profile", async ({ page, browser }) => {
const cdp = await page.context().newCDPSession(page);
await setupVirtualAuthenticator(cdp);
const stamp = Date.now();
const aEmail = `social-a-${stamp}@example.com`;
const aUsername = `sa${stamp}`;
const bEmail = `social-b-${stamp}@example.com`;
const bUsername = `sb${stamp}`;
await registerUser(page, aEmail, aUsername);
// Register B in a separate browser context so we have two independent
// sessions. New users default to `private` — B flips to public so this
// test exercises the auto-accept path.
const bCtx = await browser.newContext();
const bPage = await bCtx.newPage();
const bCdp = await bPage.context().newCDPSession(bPage);
await setupVirtualAuthenticator(bCdp);
await registerUser(bPage, bEmail, bUsername);
await setProfileVisibility(bPage, "public");
// A visits B's profile and follows.
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 });
// Follower count on B's profile should now read 1.
await page.reload();
await expect(page.getByRole("link", { name: /1\s+Followers/i })).toBeVisible();
// Unfollow.
await page.getByRole("button", { name: "Unfollow" }).click();
await expect(page.getByRole("button", { name: "Follow" })).toBeVisible({ timeout: 5000 });
await bCtx.close();
});
test("Private profile: stub for visitors, Request → Pending → Approve → full view", async ({ page, browser }) => {
const cdp = await page.context().newCDPSession(page);
await setupVirtualAuthenticator(cdp);
const stamp = Date.now();
const aEmail = `req-a-${stamp}@example.com`;
const aUsername = `ra${stamp}`;
const bEmail = `req-b-${stamp}@example.com`;
const bUsername = `rb${stamp}`;
await registerUser(page, aEmail, aUsername);
const bCtx = await browser.newContext();
const bPage = await bCtx.newPage();
const bCdp = await bPage.context().newCDPSession(bPage);
await setupVirtualAuthenticator(bCdp);
await registerUser(bPage, bEmail, bUsername);
// B stays at the default `private`. Verify by loading the profile
// anonymously and seeing the stub.
const anonCtx = await browser.newContext();
const anon = await anonCtx.newPage();
const anonResp = await anon.goto(`/users/${bUsername}`);
expect(anonResp?.status()).toBe(200);
await expect(anon.getByText(/This profile is private/i)).toBeVisible();
// A (signed in) visits B's private profile — sees stub + Request button.
await page.goto(`/users/${bUsername}`);
await expect(page.getByText(/This profile is private/i)).toBeVisible();
await expect(page.getByRole("button", { name: /Request to follow/i })).toBeVisible();
await page.getByRole("button", { name: /Request to follow/i }).click();
await expect(page.getByRole("button", { name: /Requested/i })).toBeVisible({ timeout: 5000 });
// B sees the request in the Requests tab on /notifications.
await bPage.goto("/notifications?tab=requests");
await expect(bPage.getByText(`@${aUsername}`)).toBeVisible();
await bPage.getByRole("button", { name: "Approve" }).click();
// Empty state after approval. The text-visibility assertion below
// already polls; explicit networkidle would hang on SSE.
await expect(bPage.getByText(/No pending follow requests/i)).toBeVisible();
// A reloads B's profile — now sees full content (no stub) + Unfollow.
await page.goto(`/users/${bUsername}`);
await expect(page.getByText(/This profile is private/i)).not.toBeVisible();
await expect(page.getByRole("button", { name: "Unfollow" })).toBeVisible();
await bCtx.close();
await anonCtx.close();
});
test("Private profile: visitor sees stub layout (200, not 404)", async ({ page, browser }) => {
const cdp = await page.context().newCDPSession(page);
await setupVirtualAuthenticator(cdp);
const stamp = Date.now();
const username = `priv${stamp}`;
await registerUser(page, `priv-${stamp}@example.com`, username);
// User stays at default `private`.
const anonCtx = await browser.newContext();
const anon = await anonCtx.newPage();
const resp = await anon.goto(`/users/${username}`);
expect(resp?.status()).toBe(200);
await expect(anon.getByText(/This profile is private/i)).toBeVisible();
await anonCtx.close();
});
});