diff --git a/apps/journal/app/routes/api.settings.passkey.delete.ts b/apps/journal/app/routes/api.settings.passkey.delete.ts index e96dade..cd5406e 100644 --- a/apps/journal/app/routes/api.settings.passkey.delete.ts +++ b/apps/journal/app/routes/api.settings.passkey.delete.ts @@ -23,5 +23,8 @@ export async function action({ request }: Route.ActionArgs) { 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"); } diff --git a/e2e/explore.test.ts b/e2e/explore.test.ts index 532f37b..b2c0809 100644 --- a/e2e/explore.test.ts +++ b/e2e/explore.test.ts @@ -2,7 +2,10 @@ import { test, expect, waitForHydration, type CDPSession, type Page } from "./fi import { setupVirtualAuthenticator, registerUser } from "./helpers/auth"; 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.getByRole("button", { name: /^Save$/ }).first().click(); await expect(page.getByText("Profile saved.")).toBeVisible({ timeout: 10000 }); diff --git a/e2e/settings.test.ts b/e2e/settings.test.ts index 921be62..04cb6af 100644 --- a/e2e/settings.test.ts +++ b/e2e/settings.test.ts @@ -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"; +// 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"); @@ -14,9 +22,11 @@ test.describe("Account Settings", () => { await page.goto("/settings"); await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible(); - await expect(page.getByText("Profile")).toBeVisible(); - await expect(page.getByText("Security")).toBeVisible(); - await expect(page.getByText("Account")).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); }); @@ -26,17 +36,20 @@ test.describe("Account Settings", () => { const authenticatorId = await setupVirtualAuthenticator(cdp); 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"); - await page.getByLabel("Bio").fill("I love hiking!"); + // 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.getByLabel("Display Name")).toHaveValue("Test Display Name"); - await expect(page.getByLabel("Bio")).toHaveValue("I love hiking!"); + 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}`); @@ -50,8 +63,8 @@ test.describe("Account Settings", () => { const authenticatorId = await setupVirtualAuthenticator(cdp); await registerFreshUser(page, "settings"); - await page.goto("/settings"); - // Should show the passkey registered during registration + 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(); @@ -63,7 +76,8 @@ test.describe("Account Settings", () => { const authenticatorId = await setupVirtualAuthenticator(cdp); await registerFreshUser(page, "settings"); - await page.goto("/settings"); + await page.goto("/settings/security"); + await waitForHydration(page); // Should have 1 passkey from registration const deleteButtons = page.getByRole("button", { name: "Delete" }); @@ -71,15 +85,14 @@ test.describe("Account Settings", () => { // Add another passkey 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 }); // Delete one page.on("dialog", (dialog) => dialog.accept()); 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 removeVirtualAuthenticator(cdp, authenticatorId); @@ -90,7 +103,8 @@ test.describe("Account Settings", () => { const authenticatorId = await setupVirtualAuthenticator(cdp); 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 let dialogMessage = ""; @@ -113,8 +127,9 @@ test.describe("Account Settings", () => { const authenticatorId = await setupVirtualAuthenticator(cdp); const { username } = await registerFreshUser(page, "settings"); - // Settings now lives inside the avatar dropdown; opening the - // dropdown reveals the menuitem. + // 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(); @@ -123,7 +138,7 @@ test.describe("Account Settings", () => { test("settings link not visible when logged out", async ({ page }) => { 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(); }); @@ -132,16 +147,17 @@ test.describe("Account Settings", () => { const authenticatorId = await setupVirtualAuthenticator(cdp); 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(); - // Type wrong username — button should be disabled + // Wrong username — confirm button disabled await page.getByPlaceholder(username).fill("wrongname"); 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 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 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 removeVirtualAuthenticator(cdp, authenticatorId); diff --git a/playwright.config.ts b/playwright.config.ts index c3c16f6..07140d1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ }, // NOTE: specs only run if a project below matches them — a new // e2e/*.test.ts file MUST be registered here or it silently never - // executes (settings/explore/social sat unregistered for months). + // executes. projects: [ { name: "journal", @@ -90,6 +90,30 @@ export default defineConfig({ 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 ? [