Merge branch 'main' into docs-review-decks

This commit is contained in:
Ullrich Schäfer 2026-06-11 08:37:43 +02:00 committed by GitHub
commit a38fdab69d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 73 additions and 27 deletions

View file

@ -23,5 +23,8 @@ export async function action({ request }: Route.ActionArgs) {
return data({ error: "Passkey not found" }, { status: 404 }); return data({ error: "Passkey not found" }, { status: 404 });
} }
return redirect("/settings#security"); // Settings is split into sections now; the bare /settings#security
// anchor resolves to /settings/profile. Send the user back to the
// security section they were on.
return redirect("/settings/security");
} }

View file

@ -2,7 +2,10 @@ import { test, expect, waitForHydration, type CDPSession, type Page } from "./fi
import { setupVirtualAuthenticator, registerUser } from "./helpers/auth"; import { setupVirtualAuthenticator, registerUser } from "./helpers/auth";
async function setProfileVisibility(page: Page, value: "public" | "private") { async function setProfileVisibility(page: Page, value: "public" | "private") {
await page.goto("/settings"); 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.locator(`input[type=radio][name=profileVisibility][value=${value}]`).check();
await page.getByRole("button", { name: /^Save$/ }).first().click(); await page.getByRole("button", { name: /^Save$/ }).first().click();
await expect(page.getByText("Profile saved.")).toBeVisible({ timeout: 10000 }); await expect(page.getByText("Profile saved.")).toBeVisible({ timeout: 10000 });

View file

@ -1,6 +1,14 @@
import { test, expect, type CDPSession, type Page } from "./fixtures/test"; import { test, expect, waitForHydration, type Page } from "./fixtures/test";
import { setupVirtualAuthenticator, removeVirtualAuthenticator, registerFreshUser } from "./helpers/auth"; 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.describe("Account Settings", () => {
test("unauthenticated user is redirected to login", async ({ page }) => { test("unauthenticated user is redirected to login", async ({ page }) => {
await page.goto("/settings"); await page.goto("/settings");
@ -14,9 +22,11 @@ test.describe("Account Settings", () => {
await page.goto("/settings"); await page.goto("/settings");
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
await expect(page.getByText("Profile")).toBeVisible(); // Section nav links (the avatar dropdown is closed, so these names
await expect(page.getByText("Security")).toBeVisible(); // are unique to the settings section nav).
await expect(page.getByText("Account")).toBeVisible(); 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); await removeVirtualAuthenticator(cdp, authenticatorId);
}); });
@ -26,17 +36,20 @@ test.describe("Account Settings", () => {
const authenticatorId = await setupVirtualAuthenticator(cdp); const authenticatorId = await setupVirtualAuthenticator(cdp);
const { username } = await registerFreshUser(page, "settings"); const { username } = await registerFreshUser(page, "settings");
await page.goto("/settings"); await page.goto("/settings/profile");
await waitForHydration(page);
await page.getByLabel("Display Name").fill("Test Display Name"); // Target the inputs by id — robust against the Vite dev server
await page.getByLabel("Bio").fill("I love hiking!"); // 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 page.getByRole("button", { name: "Save" }).click();
await expect(page.getByText("Profile saved")).toBeVisible({ timeout: 5000 }); await expect(page.getByText("Profile saved")).toBeVisible({ timeout: 5000 });
// Reload and verify persistence // Reload and verify persistence
await page.reload(); await page.reload();
await expect(page.getByLabel("Display Name")).toHaveValue("Test Display Name"); await expect(page.locator("#displayName")).toHaveValue("Test Display Name");
await expect(page.getByLabel("Bio")).toHaveValue("I love hiking!"); await expect(page.locator("#bio")).toHaveValue("I love hiking!");
// Verify public profile reflects changes // Verify public profile reflects changes
await page.goto(`/users/${username}`); await page.goto(`/users/${username}`);
@ -50,8 +63,8 @@ test.describe("Account Settings", () => {
const authenticatorId = await setupVirtualAuthenticator(cdp); const authenticatorId = await setupVirtualAuthenticator(cdp);
await registerFreshUser(page, "settings"); await registerFreshUser(page, "settings");
await page.goto("/settings"); await page.goto("/settings/security");
// Should show the passkey registered during registration // The passkey registered during sign-up shows as "This device".
await expect(page.getByText("This device")).toBeVisible(); await expect(page.getByText("This device")).toBeVisible();
await expect(page.getByText(/Added/)).toBeVisible(); await expect(page.getByText(/Added/)).toBeVisible();
@ -63,7 +76,8 @@ test.describe("Account Settings", () => {
const authenticatorId = await setupVirtualAuthenticator(cdp); const authenticatorId = await setupVirtualAuthenticator(cdp);
await registerFreshUser(page, "settings"); await registerFreshUser(page, "settings");
await page.goto("/settings"); await page.goto("/settings/security");
await waitForHydration(page);
// Should have 1 passkey from registration // Should have 1 passkey from registration
const deleteButtons = page.getByRole("button", { name: "Delete" }); const deleteButtons = page.getByRole("button", { name: "Delete" });
@ -71,15 +85,14 @@ test.describe("Account Settings", () => {
// Add another passkey // Add another passkey
await page.getByRole("button", { name: "Add Passkey" }).click(); await page.getByRole("button", { name: "Add Passkey" }).click();
await page.waitForTimeout(1000);
// After reload, should have 2 passkeys // After the ceremony + reload, should have 2 passkeys
await expect(deleteButtons).toHaveCount(2, { timeout: 5000 }); await expect(deleteButtons).toHaveCount(2, { timeout: 5000 });
// Delete one // Delete one
page.on("dialog", (dialog) => dialog.accept()); page.on("dialog", (dialog) => dialog.accept());
await deleteButtons.first().click(); await deleteButtons.first().click();
await expect(page).toHaveURL(/\/settings/, { timeout: 5000 }); await expect(page).toHaveURL(/\/settings\/security/, { timeout: 5000 });
await expect(page.getByRole("button", { name: "Delete" })).toHaveCount(1); await expect(page.getByRole("button", { name: "Delete" })).toHaveCount(1);
await removeVirtualAuthenticator(cdp, authenticatorId); await removeVirtualAuthenticator(cdp, authenticatorId);
@ -90,7 +103,8 @@ test.describe("Account Settings", () => {
const authenticatorId = await setupVirtualAuthenticator(cdp); const authenticatorId = await setupVirtualAuthenticator(cdp);
await registerFreshUser(page, "settings"); await registerFreshUser(page, "settings");
await page.goto("/settings"); await page.goto("/settings/security");
await waitForHydration(page);
// Intercept the confirm dialog to check it contains the warning // Intercept the confirm dialog to check it contains the warning
let dialogMessage = ""; let dialogMessage = "";
@ -113,8 +127,9 @@ test.describe("Account Settings", () => {
const authenticatorId = await setupVirtualAuthenticator(cdp); const authenticatorId = await setupVirtualAuthenticator(cdp);
const { username } = await registerFreshUser(page, "settings"); const { username } = await registerFreshUser(page, "settings");
// Settings now lives inside the avatar dropdown; opening the // Settings lives inside the avatar dropdown; wait for hydration so
// dropdown reveals the menuitem. // the dropdown's open handler is wired before clicking it.
await waitForHydration(page);
await page.getByRole("navigation").getByRole("button", { name: username }).click(); await page.getByRole("navigation").getByRole("button", { name: username }).click();
await expect(page.getByRole("menuitem", { name: "Settings" })).toBeVisible(); await expect(page.getByRole("menuitem", { name: "Settings" })).toBeVisible();
@ -123,7 +138,7 @@ test.describe("Account Settings", () => {
test("settings link not visible when logged out", async ({ page }) => { test("settings link not visible when logged out", async ({ page }) => {
await page.goto("/"); await page.goto("/");
// Anonymous navbar has neither the avatar nor the Settings link. // Anonymous navbar has neither the avatar nor a Settings link.
await expect(page.getByRole("navigation").getByRole("link", { name: "Settings" })).not.toBeVisible(); await expect(page.getByRole("navigation").getByRole("link", { name: "Settings" })).not.toBeVisible();
}); });
@ -132,16 +147,17 @@ test.describe("Account Settings", () => {
const authenticatorId = await setupVirtualAuthenticator(cdp); const authenticatorId = await setupVirtualAuthenticator(cdp);
const { username } = await registerFreshUser(page, "settings"); const { username } = await registerFreshUser(page, "settings");
await page.goto("/settings"); await page.goto("/settings/account");
await waitForHydration(page);
// Click delete account // Open the delete-account confirmation.
await page.getByRole("button", { name: "Delete Account" }).click(); await page.getByRole("button", { name: "Delete Account" }).click();
// Type wrong username — button should be disabled // Wrong username — confirm button disabled
await page.getByPlaceholder(username).fill("wrongname"); await page.getByPlaceholder(username).fill("wrongname");
await expect(page.getByRole("button", { name: "Permanently Delete" })).toBeDisabled(); await expect(page.getByRole("button", { name: "Permanently Delete" })).toBeDisabled();
// Type correct username — button should be enabled // Correct username — confirm button enabled
await page.getByPlaceholder(username).fill(username); await page.getByPlaceholder(username).fill(username);
await expect(page.getByRole("button", { name: "Permanently Delete" })).toBeEnabled(); await expect(page.getByRole("button", { name: "Permanently Delete" })).toBeEnabled();
@ -149,7 +165,7 @@ test.describe("Account Settings", () => {
await page.getByRole("button", { name: "Permanently Delete" }).click(); await page.getByRole("button", { name: "Permanently Delete" }).click();
await expect(page).toHaveURL("/", { timeout: 10000 }); await expect(page).toHaveURL("/", { timeout: 10000 });
// Session should be destroyed — nav should show login // Session destroyed — nav shows Sign In again
await expect(page.getByRole("navigation").getByRole("link", { name: "Sign In" })).toBeVisible({ timeout: 5000 }); await expect(page.getByRole("navigation").getByRole("link", { name: "Sign In" })).toBeVisible({ timeout: 5000 });
await removeVirtualAuthenticator(cdp, authenticatorId); await removeVirtualAuthenticator(cdp, authenticatorId);

View file

@ -16,7 +16,7 @@ export default defineConfig({
}, },
// NOTE: specs only run if a project below matches them — a new // NOTE: specs only run if a project below matches them — a new
// e2e/*.test.ts file MUST be registered here or it silently never // e2e/*.test.ts file MUST be registered here or it silently never
// executes (settings/explore/social sat unregistered for months). // executes.
projects: [ projects: [
{ {
name: "journal", name: "journal",
@ -90,6 +90,30 @@ export default defineConfig({
baseURL: "http://localhost:3000", baseURL: "http://localhost:3000",
}, },
}, },
{
name: "settings",
testMatch: "settings.test.ts",
use: {
...devices["Desktop Chrome"],
baseURL: "http://localhost:3000",
},
},
{
name: "explore",
testMatch: "explore.test.ts",
use: {
...devices["Desktop Chrome"],
baseURL: "http://localhost:3000",
},
},
{
name: "social",
testMatch: "social.test.ts",
use: {
...devices["Desktop Chrome"],
baseURL: "http://localhost:3000",
},
},
], ],
webServer: process.env.CI webServer: process.env.CI
? [ ? [