diff --git a/CLAUDE.md b/CLAUDE.md index f08dbb8..1f09a8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -219,6 +219,7 @@ Write tests first or alongside features. Tests serve as living documentation of - Config: `playwright.config.ts` (Chromium only, builds + previews app on port 4173) - Test database: `./data/test-crumbs.db` (cleaned via `global-setup.ts`) - Auth fixture: `tests/e2e/helpers/fixtures.ts` provides `authenticatedPage` (handles setup/login race conditions across parallel workers) +- **Shared login state**: `tests/e2e/auth.setup.ts` (project `login-state`) logs in once and saves the admin session cookie to `data/test-storage-state.json`; the `app` project loads it into every context so tests start authenticated instead of submitting the login form each time. A spec that needs an anonymous baseline opts out with `test.use({ storageState: { cookies: [], origins: [] } })` (see `pwa.spec.ts`, `mcp.spec.ts`). Never write a test that revokes or logs out the admin's own session — it would break every test in other workers - Auth tests use `test.describe.serial` because they depend on sequential database state - **Offline/online tests**: After `setOffline(false)`, always manually dispatch the `online` event via `page.evaluate(() => window.dispatchEvent(new Event('online')))` — Playwright doesn't reliably emit it in headless/CI environments, causing flaky `waitForResponse` timeouts diff --git a/playwright.config.ts b/playwright.config.ts index 7d942cc..aa0f1bb 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,4 +1,5 @@ import { defineConfig, devices } from '@playwright/test'; +import { STORAGE_STATE_FILE } from './tests/e2e/global-setup'; export default defineConfig({ testDir: './tests/e2e', @@ -22,10 +23,20 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'] } }, { - name: 'app', - testIgnore: 'auth.spec.ts', + // Logs in once and saves the session cookie for the `app` project. + name: 'login-state', + testMatch: 'auth.setup.ts', dependencies: ['auth-setup'], use: { ...devices['Desktop Chrome'] } + }, + { + name: 'app', + testIgnore: ['auth.spec.ts', 'auth.setup.ts'], + dependencies: ['login-state'], + // Every context starts with the admin already logged in. Specs that + // need an unauthenticated baseline opt out with + // `test.use({ storageState: { cookies: [], origins: [] } })`. + use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE_FILE } } ], webServer: { diff --git a/tests/e2e/auth.setup.ts b/tests/e2e/auth.setup.ts new file mode 100644 index 0000000..50ced7b --- /dev/null +++ b/tests/e2e/auth.setup.ts @@ -0,0 +1,18 @@ +import { test as setup } from '@playwright/test'; +import { setupAndLogin } from './helpers/fixtures'; +import { STORAGE_STATE_FILE } from './global-setup'; + +/** + * Log in as the admin once and persist the session cookie. The `app` project + * loads this storage state into every browser context, so tests start already + * authenticated instead of each one submitting the login form (~0.8s per test, + * roughly half of a typical test's duration). + * + * The admin's own session is never revoked by any test: admin-users only + * resets/revokes victim accounts, and auth.spec's logout scenario runs in the + * auth-setup project with its own context before this setup executes. + */ +setup('authenticate as admin and save storage state', async ({ page }) => { + await setupAndLogin(page); + await page.context().storageState({ path: STORAGE_STATE_FILE }); +}); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index 7139a3b..889fd9d 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -2,6 +2,8 @@ import { writeFileSync } from 'fs'; import { randomUUID } from 'crypto'; export const TEST_CREDENTIALS_FILE = './data/test-credentials.json'; +/** Admin session cookie captured by auth.setup.ts and shared by the `app` project. */ +export const STORAGE_STATE_FILE = './data/test-storage-state.json'; export default function globalSetup() { // The test database is wiped before the webServer starts (see the diff --git a/tests/e2e/mcp.spec.ts b/tests/e2e/mcp.spec.ts index 5f972c1..12ca86f 100644 --- a/tests/e2e/mcp.spec.ts +++ b/tests/e2e/mcp.spec.ts @@ -9,6 +9,10 @@ import { setupAndLogin } from './helpers/fixtures.js'; * 3. Test tool invocations via JSON-RPC over Streamable HTTP */ +// Start unauthenticated: the "rejects requests without API key" scenario must +// not carry the shared admin session cookie, and the apiKey fixture logs in itself. +base.use({ storageState: { cookies: [], origins: [] } }); + const test = base.extend<{ apiKey: string }>({ apiKey: async ({ page }, use) => { await setupAndLogin(page); diff --git a/tests/e2e/pwa.spec.ts b/tests/e2e/pwa.spec.ts index 32d9d02..57e2b43 100644 --- a/tests/e2e/pwa.spec.ts +++ b/tests/e2e/pwa.spec.ts @@ -1,5 +1,9 @@ import { test, expect } from '@playwright/test'; +// These scenarios describe what an anonymous visitor receives, so drop the +// shared admin session the `app` project injects. +test.use({ storageState: { cookies: [], origins: [] } }); + test.describe('PWA', () => { test('Scenario: Web manifest provides correct app metadata', async ({ page }) => { // When the browser requests the web manifest