trails/e2e/social.test.ts
Ullrich Schäfer abb754e6a5 Replace waitForLoadState("networkidle") in e2e helpers
The SSE connection to /api/events (added with notifications) keeps the
network in-flight forever, so `networkidle` never resolves. Each save
in the affected helpers timed out at 30s × 3 retries, which both broke
the run and made it suspiciously long.

Switched to explicit waits:
- "Profile saved." text after settings save (notifications + public-content + social)
- "No pending follow requests." after Approve (social.test.ts)
- toBeHidden poll for the Mark all read button (notifications)
- toBeVisible poll for the empty-state copy after Approve (notifications)

These are all the affected `networkidle` call sites in e2e. The
fetcher.Form pattern stays — once the action commits, the page
revalidates and the post-state element appears.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:37:37 +02:00

159 lines
6.8 KiB
TypeScript

import { test, expect, 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.
async function setupVirtualAuthenticator(cdp: CDPSession) {
await cdp.send("WebAuthn.enable");
const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", {
options: {
protocol: "ctap2",
transport: "internal",
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
},
});
return authenticatorId;
}
async function registerUser(page: Page, email: string, username: string) {
await page.goto("/auth/register");
await expect(page.getByRole("heading", { name: "Register" })).toBeVisible();
await page.getByLabel("Email").fill(email);
await page.getByLabel("Username").fill(username);
await page.getByRole("checkbox").check();
await page.getByRole("button", { name: /Register with Passkey/ }).click();
await expect(page).toHaveURL("/", { timeout: 10000 });
}
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.
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/requests redirects anonymous visitors to login", async ({ page }) => {
await page.goto("/follows/requests");
await expect(page).toHaveURL(/\/auth\/login/, { timeout: 10000 });
});
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 page.goto(`/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 /follows/requests with badge in nav.
await bPage.goto("/follows/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();
});
});