trails/e2e/settings.test.ts
Ullrich Schäfer 7a290cd56f
e2e: register and repair the settings/explore/social specs
settings.test.ts, explore.test.ts, and social.test.ts weren't matched
by any Playwright project, so they had silently never run — which is
how they rotted. Register them (one project each) and repair the
selectors against the current UI:

- settings: the page was split into sibling sections
  (/settings/{profile,security,account}); the spec assumed one page.
  Navigate to the right sub-page per test, use the stable section-nav
  links + #id input locators (the Vite dev server transiently
  double-renders the profile form during hydration, breaking
  getByLabel), and wait for hydration before interacting with
  fetcher-backed forms and the avatar dropdown.
- explore: setProfileVisibility now targets /settings/profile and
  waits for hydration so the visibility save uses the fetcher.
- social: already green once registered.

Also fixes an app bug the specs surfaced: deleting a passkey redirected
to /settings#security, a stale anchor that now resolves to
/settings/profile — so you'd land on the wrong section. It now
redirects to /settings/security.

Verified locally against Postgres/BRouter: green under CI-style
retries (the residual registration flake is the same cold-start class
the rest of the suite has, which is why CI runs retries=2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:32:18 +02:00

173 lines
7.3 KiB
TypeScript

import { test, expect, waitForHydration, type Page } from "./fixtures/test";
import { setupVirtualAuthenticator, removeVirtualAuthenticator, registerFreshUser } from "./helpers/auth";
// Settings is split into sibling sections, each with a stable URL:
// /settings → redirects to /settings/profile
// /settings/profile → display name, bio, visibility
// /settings/security → passkeys
// /settings/account → email change, account deletion
// The left-hand section nav lists Profile / Account / Security /
// Connected services as links.
test.describe("Account Settings", () => {
test("unauthenticated user is redirected to login", async ({ page }) => {
await page.goto("/settings");
await expect(page).toHaveURL(/\/auth\/login/, { timeout: 5000 });
});
test("settings page loads for authenticated user", async ({ page }) => {
const cdp = await page.context().newCDPSession(page);
const authenticatorId = await setupVirtualAuthenticator(cdp);
await registerFreshUser(page, "settings");
await page.goto("/settings");
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
// Section nav links (the avatar dropdown is closed, so these names
// are unique to the settings section nav).
await expect(page.getByRole("link", { name: "Profile" })).toBeVisible();
await expect(page.getByRole("link", { name: "Security" })).toBeVisible();
await expect(page.getByRole("link", { name: "Account" })).toBeVisible();
await removeVirtualAuthenticator(cdp, authenticatorId);
});
test("update display name and bio", async ({ page }) => {
const cdp = await page.context().newCDPSession(page);
const authenticatorId = await setupVirtualAuthenticator(cdp);
const { username } = await registerFreshUser(page, "settings");
await page.goto("/settings/profile");
await waitForHydration(page);
// Target the inputs by id — robust against the Vite dev server
// transiently double-rendering the form during hydration.
await page.locator("#displayName").fill("Test Display Name");
await page.locator("#bio").fill("I love hiking!");
await page.getByRole("button", { name: "Save" }).click();
await expect(page.getByText("Profile saved")).toBeVisible({ timeout: 5000 });
// Reload and verify persistence
await page.reload();
await expect(page.locator("#displayName")).toHaveValue("Test Display Name");
await expect(page.locator("#bio")).toHaveValue("I love hiking!");
// Verify public profile reflects changes
await page.goto(`/users/${username}`);
await expect(page.getByText("Test Display Name")).toBeVisible();
await removeVirtualAuthenticator(cdp, authenticatorId);
});
test("passkey list shows registered passkey", async ({ page }) => {
const cdp = await page.context().newCDPSession(page);
const authenticatorId = await setupVirtualAuthenticator(cdp);
await registerFreshUser(page, "settings");
await page.goto("/settings/security");
// The passkey registered during sign-up shows as "This device".
await expect(page.getByText("This device")).toBeVisible();
await expect(page.getByText(/Added/)).toBeVisible();
await removeVirtualAuthenticator(cdp, authenticatorId);
});
test("add and delete passkey from settings", async ({ page }) => {
const cdp = await page.context().newCDPSession(page);
const authenticatorId = await setupVirtualAuthenticator(cdp);
await registerFreshUser(page, "settings");
await page.goto("/settings/security");
await waitForHydration(page);
// Should have 1 passkey from registration
const deleteButtons = page.getByRole("button", { name: "Delete" });
await expect(deleteButtons).toHaveCount(1);
// Add another passkey
await page.getByRole("button", { name: "Add Passkey" }).click();
// After the ceremony + reload, should have 2 passkeys
await expect(deleteButtons).toHaveCount(2, { timeout: 5000 });
// Delete one
page.on("dialog", (dialog) => dialog.accept());
await deleteButtons.first().click();
await expect(page).toHaveURL(/\/settings\/security/, { timeout: 5000 });
await expect(page.getByRole("button", { name: "Delete" })).toHaveCount(1);
await removeVirtualAuthenticator(cdp, authenticatorId);
});
test("delete last passkey shows warning", async ({ page }) => {
const cdp = await page.context().newCDPSession(page);
const authenticatorId = await setupVirtualAuthenticator(cdp);
await registerFreshUser(page, "settings");
await page.goto("/settings/security");
await waitForHydration(page);
// Intercept the confirm dialog to check it contains the warning
let dialogMessage = "";
page.on("dialog", async (dialog) => {
dialogMessage = dialog.message();
await dialog.accept();
});
await page.getByRole("button", { name: "Delete" }).click();
expect(dialogMessage).toContain("only passkey");
// After deleting, should show "No passkeys registered"
await expect(page.getByText(/No passkeys registered/)).toBeVisible({ timeout: 5000 });
await removeVirtualAuthenticator(cdp, authenticatorId);
});
test("settings link reachable from nav when logged in", async ({ page }) => {
const cdp = await page.context().newCDPSession(page);
const authenticatorId = await setupVirtualAuthenticator(cdp);
const { username } = await registerFreshUser(page, "settings");
// Settings lives inside the avatar dropdown; wait for hydration so
// the dropdown's open handler is wired before clicking it.
await waitForHydration(page);
await page.getByRole("navigation").getByRole("button", { name: username }).click();
await expect(page.getByRole("menuitem", { name: "Settings" })).toBeVisible();
await removeVirtualAuthenticator(cdp, authenticatorId);
});
test("settings link not visible when logged out", async ({ page }) => {
await page.goto("/");
// Anonymous navbar has neither the avatar nor a Settings link.
await expect(page.getByRole("navigation").getByRole("link", { name: "Settings" })).not.toBeVisible();
});
test("account deletion requires correct username", async ({ page }) => {
const cdp = await page.context().newCDPSession(page);
const authenticatorId = await setupVirtualAuthenticator(cdp);
const { username } = await registerFreshUser(page, "settings");
await page.goto("/settings/account");
await waitForHydration(page);
// Open the delete-account confirmation.
await page.getByRole("button", { name: "Delete Account" }).click();
// Wrong username — confirm button disabled
await page.getByPlaceholder(username).fill("wrongname");
await expect(page.getByRole("button", { name: "Permanently Delete" })).toBeDisabled();
// Correct username — confirm button enabled
await page.getByPlaceholder(username).fill(username);
await expect(page.getByRole("button", { name: "Permanently Delete" })).toBeEnabled();
// Submit deletion
await page.getByRole("button", { name: "Permanently Delete" }).click();
await expect(page).toHaveURL("/", { timeout: 10000 });
// Session destroyed — nav shows Sign In again
await expect(page.getByRole("navigation").getByRole("link", { name: "Sign In" })).toBeVisible({ timeout: 5000 });
await removeVirtualAuthenticator(cdp, authenticatorId);
});
});