From 0e267afac76c345de01fa9f6c4824663f6511b9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 10 Jun 2026 07:31:51 +0200 Subject: [PATCH] e2e: shared auth + journal seed helpers; flag unregistered specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The virtual-authenticator setup and registerUser were copy-pasted into six spec files and had drifted: the auth spec's copy lost the final URL assertion, and the settings spec's variant skipped hydration and the Terms checkbox entirely. Seed-route boilerplate and hardcoded localhost URLs were repeated across three more files. - helpers/auth.ts: setup/removeVirtualAuthenticator, submitRegistration (no outcome assertion, for expected-failure attempts), registerUser (asserts the signed-in redirect), registerFreshUser, logout - helpers/journal.ts: JOURNAL/PLANNER base URLs, seedRoute, routeHasGeom, seedKomootConnection - nine spec files now import the helpers instead of re-deriving them Found while consolidating: settings.test.ts, explore.test.ts, and social.test.ts are not matched by any playwright project and have never run — which is how the settings spec's broken register helper survived. Registering them (and fixing whatever has rotted) is a follow-up; the config now carries a warning so the trap is at least documented. Co-Authored-By: Claude Fable 5 --- e2e/auth.test.ts | 57 +++------------------- e2e/explore.test.ts | 26 +--------- e2e/helpers/auth.ts | 84 ++++++++++++++++++++++++++++++++ e2e/helpers/journal.ts | 39 +++++++++++++++ e2e/integration.test.ts | 31 ++++-------- e2e/journal-planner-save.test.ts | 11 ++--- e2e/komoot-import.test.ts | 19 +------- e2e/notifications.test.ts | 26 +--------- e2e/public-content.test.ts | 28 +---------- e2e/settings.test.ts | 47 +++--------------- e2e/social.test.ts | 28 +---------- playwright.config.ts | 3 ++ 12 files changed, 158 insertions(+), 241 deletions(-) create mode 100644 e2e/helpers/auth.ts create mode 100644 e2e/helpers/journal.ts diff --git a/e2e/auth.test.ts b/e2e/auth.test.ts index 8cda6a3..81043e2 100644 --- a/e2e/auth.test.ts +++ b/e2e/auth.test.ts @@ -1,50 +1,5 @@ import { test, expect, waitForHydration, type CDPSession, type Page } from "./fixtures/test"; - -// Virtual authenticator helpers -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 removeVirtualAuthenticator(cdp: CDPSession, authenticatorId: string) { - await cdp.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId }); - await cdp.send("WebAuthn.disable"); -} - -async function registerUser(page: Page, email: string, username: string) { - await page.goto("/auth/register"); - await expect(page.getByRole("heading", { name: "Register" })).toBeVisible(); - await waitForHydration(page); - await page.getByLabel("Email").click(); - await page.getByLabel("Email").fill(email); - await page.getByLabel("Username").click(); - await page.getByLabel("Username").fill(username); - // Verify both fields retained values before submitting - await expect(page.getByLabel("Email")).toHaveValue(email); - await expect(page.getByLabel("Username")).toHaveValue(username); - // Accept the Terms of Service (required) - await page.getByRole("checkbox").check(); - await page.getByRole("button", { name: /Register with Passkey/ }).click(); -} - -async function logout(page: Page, accountLabel: string) { - // The account cluster lives inside an avatar dropdown now. Click the - // avatar (its aria-label is displayName || username), then click the - // Log Out menuitem inside the popup. - await waitForHydration(page); - await page.getByRole("navigation").getByRole("button", { name: accountLabel }).click(); - await page.getByRole("menuitem", { name: "Log Out" }).click(); - await expect(page.getByRole("navigation").getByRole("link", { name: "Sign In" })).toBeVisible({ timeout: 5000 }); -} +import { setupVirtualAuthenticator, removeVirtualAuthenticator, submitRegistration, logout } from "./helpers/auth"; test.describe("Passkey Authentication", () => { test("register with passkey and sign in", async ({ page }) => { @@ -55,7 +10,7 @@ test.describe("Passkey Authentication", () => { const username = `testuser${Date.now()}`; // Register - await registerUser(page, email, username); + await submitRegistration(page, email, username); await expect(page).toHaveURL("/", { timeout: 10000 }); // The avatar button's aria-label is the displayName or username; // for a freshly-registered user with no displayName set, that's @@ -95,12 +50,12 @@ test.describe("Passkey Authentication", () => { const firstUsername = `first${Date.now()}`; // Register first user - await registerUser(page, email, firstUsername); + await submitRegistration(page, email, firstUsername); await expect(page).toHaveURL("/", { timeout: 10000 }); await logout(page, firstUsername); // Try to register with same email - await registerUser(page, email, `second${Date.now()}`); + await submitRegistration(page, email, `second${Date.now()}`); await expect(page.getByText(/already in use/i)).toBeVisible({ timeout: 10000 }); await removeVirtualAuthenticator(cdp, authenticatorId); @@ -113,12 +68,12 @@ test.describe("Passkey Authentication", () => { const username = `uniq${Date.now()}`; // Register first user - await registerUser(page, `first-${Date.now()}@example.com`, username); + await submitRegistration(page, `first-${Date.now()}@example.com`, username); await expect(page).toHaveURL("/", { timeout: 10000 }); await logout(page, username); // Try to register with same username - await registerUser(page, `second-${Date.now()}@example.com`, username); + await submitRegistration(page, `second-${Date.now()}@example.com`, username); await expect(page.getByText(/already taken/i)).toBeVisible({ timeout: 10000 }); await removeVirtualAuthenticator(cdp, authenticatorId); diff --git a/e2e/explore.test.ts b/e2e/explore.test.ts index defa731..532f37b 100644 --- a/e2e/explore.test.ts +++ b/e2e/explore.test.ts @@ -1,29 +1,5 @@ import { test, expect, waitForHydration, type CDPSession, type Page } from "./fixtures/test"; - -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 waitForHydration(page); - 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 }); -} +import { setupVirtualAuthenticator, registerUser } from "./helpers/auth"; async function setProfileVisibility(page: Page, value: "public" | "private") { await page.goto("/settings"); diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts new file mode 100644 index 0000000..5d7c1e4 --- /dev/null +++ b/e2e/helpers/auth.ts @@ -0,0 +1,84 @@ +// Shared auth helpers for journal specs. These were previously +// copy-pasted per spec file and had already drifted (the auth spec's +// registerUser lost the final URL assertion the other copies had, and +// the settings spec's variant skipped hydration and the Terms +// checkbox). One canonical copy lives here. + +import { expect, waitForHydration, type CDPSession, type Page } from "../fixtures/test"; + +export async function setupVirtualAuthenticator(cdp: CDPSession): Promise { + 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; +} + +export async function removeVirtualAuthenticator( + cdp: CDPSession, + authenticatorId: string, +): Promise { + await cdp.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId }); + await cdp.send("WebAuthn.disable"); +} + +/** + * Fill and submit the registration form (passkey + Terms acceptance). + * Makes no assertion about the outcome — use this directly for + * expected-failure attempts (duplicate email/username), and + * registerUser for the success path. + */ +export async function submitRegistration( + page: Page, + email: string, + username: string, +): Promise { + await page.goto("/auth/register"); + await expect(page.getByRole("heading", { name: "Register" })).toBeVisible(); + await waitForHydration(page); + await page.getByLabel("Email").click(); + await page.getByLabel("Email").fill(email); + await page.getByLabel("Username").click(); + await page.getByLabel("Username").fill(username); + // Verify both fields retained values before submitting + await expect(page.getByLabel("Email")).toHaveValue(email); + await expect(page.getByLabel("Username")).toHaveValue(username); + // Accept the Terms of Service (required) + await page.getByRole("checkbox").check(); + await page.getByRole("button", { name: /Register with Passkey/ }).click(); +} + +/** Register a user with a passkey and wait for the signed-in redirect. */ +export async function registerUser(page: Page, email: string, username: string): Promise { + await submitRegistration(page, email, username); + await expect(page).toHaveURL("/", { timeout: 10000 }); +} + +/** Register a throwaway user with unique credentials derived from `prefix`. */ +export async function registerFreshUser( + page: Page, + prefix: string, +): Promise<{ email: string; username: string }> { + const email = `${prefix}-${Date.now()}@example.com`; + const username = `${prefix}${Date.now()}`; + await registerUser(page, email, username); + return { email, username }; +} + +export async function logout(page: Page, accountLabel: string): Promise { + // The account cluster lives inside an avatar dropdown. Click the + // avatar (its aria-label is displayName || username), then click the + // Log Out menuitem inside the popup. + await waitForHydration(page); + await page.getByRole("navigation").getByRole("button", { name: accountLabel }).click(); + await page.getByRole("menuitem", { name: "Log Out" }).click(); + await expect( + page.getByRole("navigation").getByRole("link", { name: "Sign In" }), + ).toBeVisible({ timeout: 5000 }); +} diff --git a/e2e/helpers/journal.ts b/e2e/helpers/journal.ts new file mode 100644 index 0000000..8e0745f --- /dev/null +++ b/e2e/helpers/journal.ts @@ -0,0 +1,39 @@ +// Shared journal-side constants and seed helpers. The /api/e2e/* +// endpoints only exist when the journal runs with E2E=true. + +import type { APIRequestContext } from "@playwright/test"; +import { expect } from "../fixtures/test"; + +export const JOURNAL = "http://localhost:3000"; +export const PLANNER = "http://localhost:3001"; + +/** Mint a route + single-use callback JWT via /api/e2e/seed. */ +export async function seedRoute( + request: APIRequestContext, +): Promise<{ routeId: string; token: string }> { + const resp = await request.post(`${JOURNAL}/api/e2e/seed`); + expect(resp.ok(), "POST /api/e2e/seed failed — is the journal running with E2E=true?").toBeTruthy(); + return (await resp.json()) as { routeId: string; token: string }; +} + +/** Whether the route has PostGIS geometry stored (via /api/e2e/route/:id). */ +export async function routeHasGeom( + request: APIRequestContext, + routeId: string, +): Promise { + const resp = await request.get(`${JOURNAL}/api/e2e/route/${routeId}`); + expect(resp.ok()).toBeTruthy(); + const { hasGeom } = (await resp.json()) as { hasGeom: boolean }; + return hasGeom; +} + +/** Seeds a Komoot connection for the e2e test user; returns the session cookie. */ +export async function seedKomootConnection( + request: APIRequestContext, + mode: "public" | "authenticated" = "public", +): Promise<{ cookie: string }> { + const resp = await request.post(`${JOURNAL}/api/e2e/komoot`, { data: { mode } }); + if (!resp.ok()) throw new Error(`komoot seed failed: ${resp.status()}`); + const cookie = resp.headers()["set-cookie"]; + return { cookie }; +} diff --git a/e2e/integration.test.ts b/e2e/integration.test.ts index 4fdb784..50c6811 100644 --- a/e2e/integration.test.ts +++ b/e2e/integration.test.ts @@ -1,4 +1,5 @@ import { test, expect } from "./fixtures/test"; +import { JOURNAL, PLANNER, seedRoute, routeHasGeom } from "./helpers/journal"; /** * Integration tests that require the full dev stack: @@ -9,8 +10,6 @@ import { test, expect } from "./fixtures/test"; * Locally, run `pnpm dev:full` first (with E2E=true for the callback tests). */ -const JOURNAL = "http://localhost:3000"; -const PLANNER = "http://localhost:3001"; const VALID_GPX = ` @@ -30,9 +29,7 @@ const ONE_POINT_GPX = ` test.describe("Integration: Planner callback → geometry stored", () => { test("valid GPX stores geometry atomically", async ({ request }) => { - const seedResp = await request.post(`${JOURNAL}/api/e2e/seed`); - expect(seedResp.ok()).toBeTruthy(); - const { routeId, token } = await seedResp.json() as { routeId: string; token: string }; + const { routeId, token } = await seedRoute(request); const callbackResp = await request.post(`${JOURNAL}/api/routes/${routeId}/callback`, { headers: { Authorization: `Bearer ${token}` }, @@ -40,14 +37,11 @@ test.describe("Integration: Planner callback → geometry stored", () => { }); expect(callbackResp.status()).toBe(200); - const geomResp = await request.get(`${JOURNAL}/api/e2e/route/${routeId}`); - const { hasGeom } = await geomResp.json() as { hasGeom: boolean }; - expect(hasGeom).toBe(true); + expect(await routeHasGeom(request, routeId)).toBe(true); }); test("invalid GPX returns 400 and does not store geometry", async ({ request }) => { - const seedResp = await request.post(`${JOURNAL}/api/e2e/seed`); - const { routeId, token } = await seedResp.json() as { routeId: string; token: string }; + const { routeId, token } = await seedRoute(request); const callbackResp = await request.post(`${JOURNAL}/api/routes/${routeId}/callback`, { headers: { Authorization: `Bearer ${token}` }, @@ -57,14 +51,11 @@ test.describe("Integration: Planner callback → geometry stored", () => { const body = await callbackResp.json() as { error: string }; expect(body.error).toMatch(/at least 2 track points/); - const geomResp = await request.get(`${JOURNAL}/api/e2e/route/${routeId}`); - const { hasGeom } = await geomResp.json() as { hasGeom: boolean }; - expect(hasGeom).toBe(false); + expect(await routeHasGeom(request, routeId)).toBe(false); }); test("missing token returns 401", async ({ request }) => { - const seedResp = await request.post(`${JOURNAL}/api/e2e/seed`); - const { routeId } = await seedResp.json() as { routeId: string }; + const { routeId } = await seedRoute(request); const resp = await request.post(`${JOURNAL}/api/routes/${routeId}/callback`, { data: { gpx: VALID_GPX }, @@ -73,8 +64,7 @@ test.describe("Integration: Planner callback → geometry stored", () => { }); test("token is single-use — second submit is rejected (Phase B replay guard)", async ({ request }) => { - const seedResp = await request.post(`${JOURNAL}/api/e2e/seed`); - const { routeId, token } = await seedResp.json() as { routeId: string; token: string }; + const { routeId, token } = await seedRoute(request); // First save succeeds. const first = await request.post(`${JOURNAL}/api/routes/${routeId}/callback`, { @@ -163,9 +153,7 @@ test.describe("Integration: POI metadata roundtrip", () => { `; - const seedResp = await request.post(`${JOURNAL}/api/e2e/seed`); - expect(seedResp.ok()).toBeTruthy(); - const { routeId, token } = await seedResp.json() as { routeId: string; token: string }; + const { routeId, token } = await seedRoute(request); const callbackResp = await request.post(`${JOURNAL}/api/routes/${routeId}/callback`, { headers: { Authorization: `Bearer ${token}` }, @@ -182,8 +170,7 @@ test.describe("Integration: POI metadata roundtrip", () => { }); test("GPX without POI extensions shows no waypoints section", async ({ page, request }) => { - const seedResp = await request.post(`${JOURNAL}/api/e2e/seed`); - const { routeId, token } = await seedResp.json() as { routeId: string; token: string }; + const { routeId, token } = await seedRoute(request); await request.post(`${JOURNAL}/api/routes/${routeId}/callback`, { headers: { Authorization: `Bearer ${token}` }, data: { gpx: VALID_GPX }, diff --git a/e2e/journal-planner-save.test.ts b/e2e/journal-planner-save.test.ts index 96f1a20..db0b445 100644 --- a/e2e/journal-planner-save.test.ts +++ b/e2e/journal-planner-save.test.ts @@ -17,10 +17,9 @@ // computes a route and the Save button has GPX to ship. import { test, expect } from "./fixtures/test"; +import { JOURNAL, PLANNER, seedRoute, routeHasGeom } from "./helpers/journal"; import { mockBRouter } from "./fixtures/brouter-mock"; -const JOURNAL = "http://localhost:3000"; -const PLANNER = "http://localhost:3001"; // Mock BRouter so the in-browser route compute is deterministic and // fast — same approach as `planner-coloring.test.ts`. Without this the @@ -35,9 +34,7 @@ const WAYPOINTS = encodeURIComponent(JSON.stringify([ ])); async function seedRouteAndPlannerSession(request: import("@playwright/test").APIRequestContext) { - const seedResp = await request.post(`${JOURNAL}/api/e2e/seed`); - expect(seedResp.ok()).toBeTruthy(); - const { routeId, token } = (await seedResp.json()) as { routeId: string; token: string }; + const { routeId, token } = await seedRoute(request); // Create a planner session with the journal callback wired up — no // GPX seeded into the session itself. We pass waypoints via URL @@ -85,9 +82,7 @@ test.describe("Journal ↔ Planner save handoff (Phase A + B)", () => { expect(observed.some((s) => s.includes(token))).toBe(false); // Sanity: the journal's route now has geometry. - const geomResp = await request.get(`${JOURNAL}/api/e2e/route/${routeId}`); - const { hasGeom } = (await geomResp.json()) as { hasGeom: boolean }; - expect(hasGeom).toBe(true); + expect(await routeHasGeom(request, routeId)).toBe(true); }); test("token is single-use — second save through the planner UI fails", async ({ page, request }) => { diff --git a/e2e/komoot-import.test.ts b/e2e/komoot-import.test.ts index d58ca37..4d8620a 100644 --- a/e2e/komoot-import.test.ts +++ b/e2e/komoot-import.test.ts @@ -1,8 +1,8 @@ import { test, expect } from "./fixtures/test"; +import { JOURNAL, seedKomootConnection, seedRoute } from "./helpers/journal"; // Fixed test data — the e2e seed endpoint creates a stable user + Komoot connection const KOMOOT_USER_ID = "99999999999"; -const JOURNAL = "http://localhost:3000"; const SAMPLE_GPX = ` @@ -34,19 +34,6 @@ const MOCK_TOURS_RESPONSE = { page: { totalPages: 1, number: 0 }, }; -// Seeds a Komoot connection for the e2e test user and returns a session cookie. -async function seedKomootConnection( - request: import("@playwright/test").APIRequestContext, - mode: "public" | "authenticated" = "public", -) { - const resp = await request.post(`${JOURNAL}/api/e2e/komoot`, { - data: { mode }, - }); - if (!resp.ok()) throw new Error(`komoot seed failed: ${resp.status()}`); - const cookie = resp.headers()["set-cookie"]; - return { cookie }; -} - test.describe("Komoot connection page", () => { test("unauthenticated user is redirected to login", async ({ page }) => { await page.goto("/settings/connections/komoot"); @@ -105,10 +92,8 @@ test.describe("Komoot import page", () => { test.setTimeout(60000); test("redirects to connect page when not connected", async ({ page, request }) => { - // Seed user without Komoot connection (use seed endpoint to just get a session) - const seedResp = await request.post(`${JOURNAL}/api/e2e/seed`); - const data = await seedResp.json() as { routeId: string; token: string }; // We only need the session — navigate to import page unauthenticated redirects to login + await seedRoute(request); await page.goto("/sync/import/komoot"); await expect(page).toHaveURL(/\/auth\/login|\/settings\/connections\/komoot/, { timeout: 5000 }); }); diff --git a/e2e/notifications.test.ts b/e2e/notifications.test.ts index e7549a9..b53a800 100644 --- a/e2e/notifications.test.ts +++ b/e2e/notifications.test.ts @@ -1,29 +1,5 @@ import { test, expect, waitForHydration, type CDPSession, type Page } from "./fixtures/test"; - -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 waitForHydration(page); - 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 }); -} +import { setupVirtualAuthenticator, registerUser } from "./helpers/auth"; async function setProfileVisibility(page: Page, value: "public" | "private") { await page.goto("/settings"); diff --git a/e2e/public-content.test.ts b/e2e/public-content.test.ts index 449633b..88c8106 100644 --- a/e2e/public-content.test.ts +++ b/e2e/public-content.test.ts @@ -1,31 +1,5 @@ import { test, expect, waitForHydration, type CDPSession, type Page } from "./fixtures/test"; - -// Reuses the virtual-authenticator + register helpers from auth.test.ts. -// Inlined rather than factored out to keep this file independently runnable. -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 waitForHydration(page); - 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 }); -} +import { setupVirtualAuthenticator, registerUser } from "./helpers/auth"; async function createRoute(page: Page, name: string): Promise { await page.goto("/routes/new"); diff --git a/e2e/settings.test.ts b/e2e/settings.test.ts index fe7aac0..921be62 100644 --- a/e2e/settings.test.ts +++ b/e2e/settings.test.ts @@ -1,36 +1,5 @@ import { test, expect, type CDPSession, type Page } from "./fixtures/test"; - -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 removeVirtualAuthenticator(cdp: CDPSession, authenticatorId: string) { - await cdp.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId }); - await cdp.send("WebAuthn.disable"); -} - -async function registerAndLogin(page: Page, cdp: CDPSession) { - const email = `settings-${Date.now()}@example.com`; - const username = `settingsuser${Date.now()}`; - - await page.goto("/auth/register"); - await page.getByLabel("Email").fill(email); - await page.getByLabel("Username").fill(username); - await page.getByRole("button", { name: /Register with Passkey/ }).click(); - await expect(page).toHaveURL("/", { timeout: 10000 }); - - return { email, username }; -} +import { setupVirtualAuthenticator, removeVirtualAuthenticator, registerFreshUser } from "./helpers/auth"; test.describe("Account Settings", () => { test("unauthenticated user is redirected to login", async ({ page }) => { @@ -41,7 +10,7 @@ test.describe("Account Settings", () => { test("settings page loads for authenticated user", async ({ page }) => { const cdp = await page.context().newCDPSession(page); const authenticatorId = await setupVirtualAuthenticator(cdp); - await registerAndLogin(page, cdp); + await registerFreshUser(page, "settings"); await page.goto("/settings"); await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible(); @@ -55,7 +24,7 @@ test.describe("Account Settings", () => { test("update display name and bio", async ({ page }) => { const cdp = await page.context().newCDPSession(page); const authenticatorId = await setupVirtualAuthenticator(cdp); - const { username } = await registerAndLogin(page, cdp); + const { username } = await registerFreshUser(page, "settings"); await page.goto("/settings"); @@ -79,7 +48,7 @@ test.describe("Account Settings", () => { test("passkey list shows registered passkey", async ({ page }) => { const cdp = await page.context().newCDPSession(page); const authenticatorId = await setupVirtualAuthenticator(cdp); - await registerAndLogin(page, cdp); + await registerFreshUser(page, "settings"); await page.goto("/settings"); // Should show the passkey registered during registration @@ -92,7 +61,7 @@ test.describe("Account Settings", () => { test("add and delete passkey from settings", async ({ page }) => { const cdp = await page.context().newCDPSession(page); const authenticatorId = await setupVirtualAuthenticator(cdp); - await registerAndLogin(page, cdp); + await registerFreshUser(page, "settings"); await page.goto("/settings"); @@ -119,7 +88,7 @@ test.describe("Account Settings", () => { test("delete last passkey shows warning", async ({ page }) => { const cdp = await page.context().newCDPSession(page); const authenticatorId = await setupVirtualAuthenticator(cdp); - await registerAndLogin(page, cdp); + await registerFreshUser(page, "settings"); await page.goto("/settings"); @@ -142,7 +111,7 @@ test.describe("Account Settings", () => { 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 registerAndLogin(page, cdp); + const { username } = await registerFreshUser(page, "settings"); // Settings now lives inside the avatar dropdown; opening the // dropdown reveals the menuitem. @@ -161,7 +130,7 @@ test.describe("Account Settings", () => { test("account deletion requires correct username", async ({ page }) => { const cdp = await page.context().newCDPSession(page); const authenticatorId = await setupVirtualAuthenticator(cdp); - const { username } = await registerAndLogin(page, cdp); + const { username } = await registerFreshUser(page, "settings"); await page.goto("/settings"); diff --git a/e2e/social.test.ts b/e2e/social.test.ts index 210afd9..ab4006f 100644 --- a/e2e/social.test.ts +++ b/e2e/social.test.ts @@ -1,31 +1,5 @@ import { test, expect, waitForHydration, 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 waitForHydration(page); - 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 }); -} +import { setupVirtualAuthenticator, registerUser } from "./helpers/auth"; async function setProfileVisibility(page: Page, value: "public" | "private") { await page.goto("/settings"); diff --git a/playwright.config.ts b/playwright.config.ts index 9b86a42..c3c16f6 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -14,6 +14,9 @@ export default defineConfig({ trace: "on-first-retry", screenshot: "only-on-failure", }, + // 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). projects: [ { name: "journal",