porchlight/docs/plans/2026-02-16-e2e-tests-plan.md

820 lines
28 KiB
Markdown

# E2E Playwright Tests Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Comprehensive end-to-end browser tests covering all user-facing flows in Porchlight.
**Architecture:** Each test file covers one functional area. Tests use raw Playwright (no test framework) with a shared assertion helper pattern. Server-side state is set up via direct HTTP calls to the API where the browser UI can't reach (e.g., creating users/magic links). The existing `run.sh` handles app lifecycle.
**Tech Stack:** Playwright (Node.js), Chromium headless, HTMX-aware testing patterns.
---
## Test Inventory
The app has these testable user flows:
| Flow | Route(s) | Test File |
|---|---|---|
| Login page (existing) | `GET /login` | `test_login.js` (done) |
| Password auth + error states | `POST /login/password` | `test_password_auth.js` |
| Magic link registration | `GET /register/{token}` | `test_registration.js` |
| Credential management page | `GET /manage/credentials` | `test_credentials.js` |
| Set/change password (HTMX) | `POST /manage/credentials/password` | `test_credentials.js` |
| Auth guard (redirect to login) | All `/manage/*` routes | `test_auth_guard.js` |
| Health endpoint | `GET /health` | `test_health.js` |
WebAuthn flows (`/login/webauthn/*`, `/manage/credentials/webauthn/*`) require hardware key simulation which is complex. They are excluded from this plan and noted as a future enhancement.
---
## Shared Helper
All test files duplicate the `assert()` pattern. Before writing new tests, extract a shared helper.
---
### Task 1: Extract shared test helper
**Files:**
- Create: `tests/e2e/helpers.js`
- Modify: `tests/e2e/test_login.js`
**Step 1: Create `tests/e2e/helpers.js`**
```javascript
// tests/e2e/helpers.js
// Shared utilities for Porchlight e2e tests.
const { chromium } = require('playwright');
const TARGET_URL = process.env.TARGET_URL || 'http://localhost:8099';
/**
* Simple test runner with pass/fail counting.
*
* Usage:
* const { run } = require('./helpers');
* run(async (page, assert) => {
* await page.goto(TARGET_URL + '/login');
* assert(true, 'page loaded');
* });
*/
async function run(testFn) {
let passed = 0;
let failed = 0;
function assert(condition, description) {
if (condition) {
console.log(` PASS: ${description}`);
passed++;
} else {
console.log(` FAIL: ${description}`);
failed++;
}
}
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
try {
await testFn(page, assert);
} finally {
await browser.close();
}
console.log(`\n========================================`);
console.log(`Results: ${passed} passed, ${failed} failed`);
console.log(`========================================\n`);
process.exit(failed > 0 ? 1 : 0);
}
module.exports = { TARGET_URL, run };
```
**Step 2: Refactor `tests/e2e/test_login.js` to use the helper**
Replace the boilerplate (chromium launch, assert function, browser.close, summary) with:
```javascript
const { TARGET_URL, run } = require('./helpers');
run(async (page, assert) => {
// ... all existing test logic stays the same, just remove boilerplate ...
});
```
Keep all existing assertions unchanged. Only remove duplicated setup/teardown.
**Step 3: Run test to verify refactor is clean**
Run: `./tests/e2e/run.sh tests/e2e/test_login.js`
Expected: 28 passed, 0 failed
**Step 4: Commit**
```bash
git add tests/e2e/helpers.js tests/e2e/test_login.js
git commit -m "refactor: extract shared e2e test helper with assert runner"
```
---
### Task 2: Password authentication test
**Files:**
- Create: `tests/e2e/test_password_auth.js`
This test needs an existing user with a password. Since there's no admin API to create users directly via the browser, we use the magic link registration flow as setup, then set a password, and test login.
**Step 1: Create `tests/e2e/test_password_auth.js`**
```javascript
// tests/e2e/test_password_auth.js
// Tests password login flow: successful login, invalid password, nonexistent user.
const { TARGET_URL, run } = require('./helpers');
run(async (page, assert) => {
// ---- Setup: Register a user via magic link API ----
// Create a magic link by hitting the app's internal service.
// We do this by making a direct API call to create a magic link token,
// then visiting the registration URL.
console.log('\n--- Setup: create test user ---');
// Use the /register endpoint with a token created via the API.
// The app uses MagicLinkService internally. Since we don't have an admin API,
// we'll use a helper endpoint approach: insert via direct DB calls isn't possible
// in e2e. Instead, we use Playwright's request context to call a setup script.
//
// Alternative: Create magic link via a Python helper script.
// For now, we use a pragmatic approach: start a Python subprocess to create a
// magic link and return the token.
// Execute setup script that creates a user with a password and returns credentials
const { execSync } = require('child_process');
const setupResult = execSync(
`uv run python -c "
import asyncio, json, aiosqlite
from pathlib import Path
from fastapi_oidc_op.config import Settings, StorageBackend
from fastapi_oidc_op.store.sqlite.repositories import SQLiteUserRepository, SQLiteCredentialRepository, SQLiteMagicLinkRepository
from fastapi_oidc_op.store.sqlite.migrations import run_migrations
from fastapi_oidc_op.invite.service import MagicLinkService
async def setup():
# Connect to the same DB the running app uses (in-memory won't work across processes)
# So we use the magic link approach: create link, return token
# Actually for :memory: DB, we must go through the running app's HTTP interface.
# Use the health endpoint to verify the app is up, then we need another approach.
#
# Best approach: just test the flows we can through the browser.
pass
asyncio.run(setup())
"`,
{ encoding: 'utf-8' }
);
// REVISED APPROACH: Since the app uses :memory: SQLite, we cannot access the DB
// from outside the process. Instead, we test what we can through the browser:
//
// 1. Go to login page
// 2. Try to login with nonexistent user (should show error)
// 3. Try to login with empty fields (browser validation)
// ---- Test: Login with nonexistent user ----
console.log('\n--- Login with nonexistent user ---');
await page.goto(`${TARGET_URL}/login`);
// Fill in form and submit via HTMX
await page.fill('#username', 'nonexistent');
await page.fill('#password', 'wrongpassword');
await page.click('form[hx-post="/login/password"] button[type="submit"]');
// Wait for HTMX response
await page.waitForSelector('[role="alert"]', { timeout: 5000 });
const errorText = await page.locator('[role="alert"]').textContent();
assert(
errorText.includes('Invalid username or password'),
`Shows error for nonexistent user (got: "${errorText}")`
);
// ---- Test: Login with empty username (browser validation) ----
console.log('\n--- Browser form validation ---');
await page.goto(`${TARGET_URL}/login`);
// The username field has `required` attribute, so clicking submit with empty
// fields should not send the request. We verify the field has the required attribute.
const usernameRequired = await page.locator('#username').getAttribute('required');
assert(usernameRequired !== null, 'Username field has required attribute');
const passwordRequired = await page.locator('#password').getAttribute('required');
assert(passwordRequired !== null, 'Password field has required attribute');
// ---- Test: HTMX attributes are correct ----
console.log('\n--- HTMX form configuration ---');
const hxPost = await page.locator('form[hx-post="/login/password"]').getAttribute('hx-post');
assert(hxPost === '/login/password', `Form posts to /login/password`);
const hxTarget = await page.locator('form[hx-post="/login/password"]').getAttribute('hx-target');
assert(hxTarget === '#login-error', `Form targets #login-error div`);
const hxSwap = await page.locator('form[hx-post="/login/password"]').getAttribute('hx-swap');
assert(hxSwap === 'innerHTML', `Form swaps innerHTML`);
});
```
**NOTE:** The `:memory:` SQLite database is only accessible within the running uvicorn process. E2E tests cannot seed data externally. The password auth test is therefore limited to testing error states and form behavior that doesn't require pre-existing users. Full login-success testing requires either:
- (a) A test setup endpoint (future)
- (b) Using a file-based SQLite path for e2e so a setup script can seed data
- (c) Testing through the magic link registration flow first (Task 3)
We take approach (c): Task 3 will test the full happy path (register via magic link → set password → logout → login with password).
**Step 2: Run the test**
Run: `./tests/e2e/run.sh tests/e2e/test_password_auth.js`
Expected: All PASS
**Step 3: Commit**
```bash
git add tests/e2e/test_password_auth.js
git commit -m "test: add e2e test for password login error states and form validation"
```
---
### Task 3: Magic link registration + full login flow
This is the most important test — it exercises the complete user journey:
1. Visit magic link → user created, redirected to credentials page
2. Set password on credentials page
3. Logout
4. Login with the password just set
Since we can't create magic links from outside the process, we need to change `run.sh` to use a file-based SQLite DB and add a setup script. Alternatively, we add a **test-only setup endpoint** to the app that is only active when `debug=True`.
**Files:**
- Modify: `tests/e2e/run.sh` — use file-based SQLite in a temp directory
- Create: `tests/e2e/setup_db.py` — Python script to seed test data into the SQLite DB
- Create: `tests/e2e/test_full_flow.js` — full registration → password → logout → login test
**Step 1: Modify `run.sh` to use file-based SQLite**
Change the SQLite path from `:memory:` to a temp file:
```bash
# Near the top, after SCRIPT_DIR/PROJECT_ROOT:
E2E_TMPDIR="$(mktemp -d)"
export OIDC_OP_SQLITE_PATH="${E2E_TMPDIR}/e2e_test.db"
# In the cleanup function, add:
rm -rf "$E2E_TMPDIR"
```
Update the uvicorn start command to use `OIDC_OP_SQLITE_PATH` from the environment (remove the `:memory:` override).
**Step 2: Create `tests/e2e/setup_db.py`**
```python
#!/usr/bin/env python3
"""Seed the e2e test database with test fixtures.
Outputs JSON with the created test data (magic link tokens, usernames, etc.)
so the JS tests can use them.
Requires OIDC_OP_SQLITE_PATH env var pointing to the app's SQLite DB.
"""
import asyncio
import json
import os
import sys
import aiosqlite
from fastapi_oidc_op.authn.password import PasswordService
from fastapi_oidc_op.invite.service import MagicLinkService
from fastapi_oidc_op.models import PasswordCredential, User
from fastapi_oidc_op.store.sqlite.repositories import (
SQLiteCredentialRepository,
SQLiteMagicLinkRepository,
SQLiteUserRepository,
)
async def seed():
db_path = os.environ.get("OIDC_OP_SQLITE_PATH")
if not db_path:
print("OIDC_OP_SQLITE_PATH not set", file=sys.stderr)
sys.exit(1)
db = await aiosqlite.connect(db_path)
db.row_factory = aiosqlite.Row
user_repo = SQLiteUserRepository(db)
cred_repo = SQLiteCredentialRepository(db)
magic_link_repo = SQLiteMagicLinkRepository(db)
password_service = PasswordService()
magic_link_service = MagicLinkService(repo=magic_link_repo)
result = {}
# 1. Create a magic link for registration test
link = await magic_link_service.create(username="newuser")
result["register_token"] = link.token
result["register_username"] = "newuser"
# 2. Create a user with a password for login test
user = User(userid="test-user-01", username="testuser", groups=["users"])
await user_repo.create(user)
password_hash = password_service.hash("testpassword123")
await cred_repo.create_password(
PasswordCredential(user_id=user.userid, password_hash=password_hash)
)
result["login_username"] = "testuser"
result["login_password"] = "testpassword123"
# 3. Create an expired/used magic link for negative test
expired_link = await magic_link_service.create(username="expired")
await magic_link_service.mark_used(expired_link.token)
result["used_token"] = expired_link.token
await db.close()
print(json.dumps(result))
asyncio.run(seed())
```
**Step 3: Update `run.sh` to run setup script after server is healthy**
After the "Server ready." line, add:
```bash
# --- Seed test data ---
echo "Seeding test data..."
E2E_FIXTURES=$(uv run --directory "$PROJECT_ROOT" python tests/e2e/setup_db.py)
export E2E_FIXTURES
echo "Test fixtures: ${E2E_FIXTURES}"
```
**Step 4: Create `tests/e2e/test_full_flow.js`**
```javascript
// tests/e2e/test_full_flow.js
// Full user journey: magic link registration → set password → logout → login.
const { TARGET_URL, run } = require('./helpers');
run(async (page, assert) => {
const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}');
assert(fixtures.register_token, 'Test fixtures loaded (register_token present)');
// ---- Step 1: Register via magic link ----
console.log('\n--- Magic link registration ---');
await page.goto(`${TARGET_URL}/register/${fixtures.register_token}`);
// Should redirect to /manage/credentials?setup=1
await page.waitForURL('**/manage/credentials?setup=1', { timeout: 5000 });
assert(
page.url().includes('/manage/credentials'),
`Redirected to credentials page (url: ${page.url()})`
);
// Should show welcome message
const welcome = page.locator('[role="status"]');
assert(await welcome.isVisible(), 'Welcome/setup message is visible');
const welcomeText = await welcome.textContent();
assert(
welcomeText.includes('Welcome'),
`Welcome message shown (got: "${welcomeText}")`
);
// Page title should be Porchlight
const title = await page.title();
assert(title.includes('Porchlight'), `Credentials page title contains Porchlight`);
// ---- Step 2: Set password ----
console.log('\n--- Set password ---');
const passwordInput = page.locator('#password');
const confirmInput = page.locator('#confirm');
assert(await passwordInput.isVisible(), 'Password input is visible');
assert(await confirmInput.isVisible(), 'Confirm password input is visible');
await passwordInput.fill('mypassword123');
await confirmInput.fill('mypassword123');
await page.click('#password-section button[type="submit"]');
// Wait for HTMX response — should show success
await page.waitForSelector('[role="status"]', { timeout: 5000 });
// Find the status message inside #password-section
const successMsg = page.locator('#password-section [role="status"]');
await successMsg.waitFor({ timeout: 5000 });
const successText = await successMsg.textContent();
assert(
successText.includes('Password updated'),
`Password set successfully (got: "${successText}")`
);
// ---- Step 3: Logout ----
console.log('\n--- Logout ---');
// The app uses POST /logout with HX-Redirect. We'll call it via fetch.
const logoutResp = await page.request.post(`${TARGET_URL}/logout`);
assert(logoutResp.ok() || logoutResp.status() === 200, `Logout returns OK`);
// Navigate to credentials — should redirect to login
await page.goto(`${TARGET_URL}/manage/credentials`);
await page.waitForURL('**/login', { timeout: 5000 });
assert(page.url().includes('/login'), `Redirected to login after logout`);
// ---- Step 4: Login with the password we just set ----
console.log('\n--- Login with new password ---');
await page.fill('#username', fixtures.register_username);
await page.fill('#password', 'mypassword123');
// Submit via HTMX — on success, HX-Redirect header triggers redirect
await page.click('form[hx-post="/login/password"] button[type="submit"]');
// Wait for redirect to credentials page
await page.waitForURL('**/manage/credentials', { timeout: 5000 });
assert(
page.url().includes('/manage/credentials'),
`Login succeeded, redirected to credentials (url: ${page.url()})`
);
// Should NOT show setup message (no ?setup=1)
const setupMsgCount = await page.locator('[role="status"]:has-text("Welcome")').count();
assert(setupMsgCount === 0, 'No welcome/setup message on normal login');
});
```
**Step 5: Run tests**
Run: `./tests/e2e/run.sh tests/e2e/test_full_flow.js`
Expected: All PASS
**Step 6: Commit**
```bash
git add tests/e2e/run.sh tests/e2e/setup_db.py tests/e2e/test_full_flow.js
git commit -m "test: add full user journey e2e test (register, set password, logout, login)"
```
---
### Task 4: Auth guard test
**Files:**
- Create: `tests/e2e/test_auth_guard.js`
Tests that unauthenticated users are redirected to `/login` when accessing protected pages.
**Step 1: Create `tests/e2e/test_auth_guard.js`**
```javascript
// tests/e2e/test_auth_guard.js
// Tests that protected routes redirect unauthenticated users to /login.
const { TARGET_URL, run } = require('./helpers');
run(async (page, assert) => {
// ---- Unauthenticated access to /manage/credentials ----
console.log('\n--- Auth guard: /manage/credentials ---');
await page.goto(`${TARGET_URL}/manage/credentials`);
await page.waitForURL('**/login', { timeout: 5000 });
assert(page.url().includes('/login'), `GET /manage/credentials redirects to /login`);
// ---- Unauthenticated access to /manage/credentials?setup=1 ----
console.log('\n--- Auth guard: /manage/credentials?setup=1 ---');
await page.goto(`${TARGET_URL}/manage/credentials?setup=1`);
await page.waitForURL('**/login', { timeout: 5000 });
assert(page.url().includes('/login'), `GET /manage/credentials?setup=1 redirects to /login`);
});
```
**Step 2: Run test**
Run: `./tests/e2e/run.sh tests/e2e/test_auth_guard.js`
Expected: All PASS
**Step 3: Commit**
```bash
git add tests/e2e/test_auth_guard.js
git commit -m "test: add e2e auth guard test for protected routes"
```
---
### Task 5: Password login error states test
**Files:**
- Create: `tests/e2e/test_password_auth.js`
**Step 1: Create `tests/e2e/test_password_auth.js`**
```javascript
// tests/e2e/test_password_auth.js
// Tests password login error states: wrong password, nonexistent user, form validation.
const { TARGET_URL, run } = require('./helpers');
run(async (page, assert) => {
const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}');
// ---- Test: Nonexistent user ----
console.log('\n--- Login: nonexistent user ---');
await page.goto(`${TARGET_URL}/login`);
await page.fill('#username', 'nobody');
await page.fill('#password', 'whatever');
await page.click('form[hx-post="/login/password"] button[type="submit"]');
await page.waitForSelector('[role="alert"]', { timeout: 5000 });
const error1 = await page.locator('[role="alert"]').textContent();
assert(
error1.includes('Invalid username or password'),
`Error shown for nonexistent user (got: "${error1}")`
);
// ---- Test: Wrong password for existing user ----
console.log('\n--- Login: wrong password ---');
await page.goto(`${TARGET_URL}/login`);
await page.fill('#username', fixtures.login_username);
await page.fill('#password', 'wrongpassword');
await page.click('form[hx-post="/login/password"] button[type="submit"]');
await page.waitForSelector('[role="alert"]', { timeout: 5000 });
const error2 = await page.locator('[role="alert"]').textContent();
assert(
error2.includes('Invalid username or password'),
`Error shown for wrong password (got: "${error2}")`
);
// ---- Test: Successful login ----
console.log('\n--- Login: correct password ---');
await page.goto(`${TARGET_URL}/login`);
await page.fill('#username', fixtures.login_username);
await page.fill('#password', fixtures.login_password);
await page.click('form[hx-post="/login/password"] button[type="submit"]');
await page.waitForURL('**/manage/credentials', { timeout: 5000 });
assert(
page.url().includes('/manage/credentials'),
`Successful login redirects to credentials (url: ${page.url()})`
);
// ---- Test: Form validation attributes ----
console.log('\n--- Form validation attributes ---');
await page.goto(`${TARGET_URL}/login`);
const usernameRequired = await page.locator('#username').getAttribute('required');
assert(usernameRequired !== null, 'Username has required attribute');
const passwordRequired = await page.locator('#password').getAttribute('required');
assert(passwordRequired !== null, 'Password has required attribute');
const usernameAutocomplete = await page.locator('#username').getAttribute('autocomplete');
assert(usernameAutocomplete === 'username', `Username autocomplete is "username"`);
const passwordAutocomplete = await page.locator('#password').getAttribute('autocomplete');
assert(passwordAutocomplete === 'current-password', `Password autocomplete is "current-password"`);
});
```
**Step 2: Run test**
Run: `./tests/e2e/run.sh tests/e2e/test_password_auth.js`
Expected: All PASS
**Step 3: Commit**
```bash
git add tests/e2e/test_password_auth.js
git commit -m "test: add e2e password auth test with error states and successful login"
```
---
### Task 6: Credentials page test
**Files:**
- Create: `tests/e2e/test_credentials.js`
Tests the credential management page when authenticated: page structure, set password (validation errors), change password.
**Step 1: Create `tests/e2e/test_credentials.js`**
```javascript
// tests/e2e/test_credentials.js
// Tests credential management page: structure, password set/change, validation errors.
const { TARGET_URL, run } = require('./helpers');
run(async (page, assert) => {
const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}');
// ---- Setup: Log in first ----
console.log('\n--- Setup: login ---');
await page.goto(`${TARGET_URL}/login`);
await page.fill('#username', fixtures.login_username);
await page.fill('#password', fixtures.login_password);
await page.click('form[hx-post="/login/password"] button[type="submit"]');
await page.waitForURL('**/manage/credentials', { timeout: 5000 });
// ---- Page structure ----
console.log('\n--- Credentials page structure ---');
const title = await page.title();
assert(title.includes('Credentials'), `Title contains "Credentials" (got: "${title}")`);
assert(title.includes('Porchlight'), `Title contains "Porchlight" (got: "${title}")`);
const h1 = await page.locator('h1').textContent();
assert(h1 === 'Credentials', `H1 says "Credentials" (got: "${h1}")`);
// Security keys section
const securityKeysH2 = page.locator('h2:has-text("Security keys")');
assert(await securityKeysH2.isVisible(), 'Security keys heading visible');
const registerBtn = page.locator('#webauthn-register-btn');
assert(await registerBtn.isVisible(), 'Add security key button visible');
// Password section
const passwordH2 = page.locator('h2:has-text("Password")');
assert(await passwordH2.isVisible(), 'Password heading visible');
const passwordStatus = page.locator('#password-section');
assert(await passwordStatus.isVisible(), 'Password section visible');
// ---- Password validation: mismatch ----
console.log('\n--- Password validation: mismatch ---');
await page.fill('#password', 'newpassword1');
await page.fill('#confirm', 'newpassword2');
await page.click('#password-section button[type="submit"]');
await page.waitForSelector('#password-section [role="alert"]', { timeout: 5000 });
const mismatchErr = await page.locator('#password-section [role="alert"]').textContent();
assert(
mismatchErr.includes('do not match'),
`Shows mismatch error (got: "${mismatchErr}")`
);
// ---- Password validation: too short ----
console.log('\n--- Password validation: too short ---');
// Reload page to clear HTMX state
await page.goto(`${TARGET_URL}/manage/credentials`);
await page.fill('#password', 'short');
await page.fill('#confirm', 'short');
await page.click('#password-section button[type="submit"]');
await page.waitForSelector('#password-section [role="alert"]', { timeout: 5000 });
const shortErr = await page.locator('#password-section [role="alert"]').textContent();
assert(
shortErr.includes('at least 8 characters'),
`Shows too-short error (got: "${shortErr}")`
);
// ---- Password change: success ----
console.log('\n--- Password change: success ---');
await page.goto(`${TARGET_URL}/manage/credentials`);
await page.fill('#password', 'newpassword123');
await page.fill('#confirm', 'newpassword123');
await page.click('#password-section button[type="submit"]');
await page.waitForSelector('#password-section [role="status"]', { timeout: 5000 });
const successMsg = await page.locator('#password-section [role="status"]').textContent();
assert(
successMsg.includes('Password updated'),
`Shows success message (got: "${successMsg}")`
);
});
```
**Step 2: Run test**
Run: `./tests/e2e/run.sh tests/e2e/test_credentials.js`
Expected: All PASS
**Step 3: Commit**
```bash
git add tests/e2e/test_credentials.js
git commit -m "test: add e2e credential management test with password validation"
```
---
### Task 7: Registration error states test
**Files:**
- Create: `tests/e2e/test_registration.js`
Tests magic link edge cases: used token, invalid token.
**Step 1: Create `tests/e2e/test_registration.js`**
```javascript
// tests/e2e/test_registration.js
// Tests magic link registration error states.
const { TARGET_URL, run } = require('./helpers');
run(async (page, assert) => {
const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}');
// ---- Invalid token ----
console.log('\n--- Invalid registration token ---');
const resp1 = await page.goto(`${TARGET_URL}/register/invalid-token-12345`);
assert(resp1.status() === 400, `Invalid token returns 400 (got: ${resp1.status()})`);
const body1 = await page.locator('body').textContent();
assert(
body1.includes('Invalid or expired'),
`Shows invalid/expired message (got: "${body1.trim()}")`
);
// ---- Used token ----
console.log('\n--- Used registration token ---');
assert(fixtures.used_token, 'Used token fixture exists');
const resp2 = await page.goto(`${TARGET_URL}/register/${fixtures.used_token}`);
assert(resp2.status() === 400, `Used token returns 400 (got: ${resp2.status()})`);
const body2 = await page.locator('body').textContent();
assert(
body2.includes('Invalid or expired'),
`Shows invalid/expired for used token (got: "${body2.trim()}")`
);
});
```
**Step 2: Run test**
Run: `./tests/e2e/run.sh tests/e2e/test_registration.js`
Expected: All PASS
**Step 3: Commit**
```bash
git add tests/e2e/test_registration.js
git commit -m "test: add e2e registration error states test (invalid/used tokens)"
```
---
### Task 8: Health endpoint test
**Files:**
- Create: `tests/e2e/test_health.js`
Simple smoke test for the health endpoint.
**Step 1: Create `tests/e2e/test_health.js`**
```javascript
// tests/e2e/test_health.js
// Smoke test: health endpoint returns OK.
const { TARGET_URL, run } = require('./helpers');
run(async (page, assert) => {
console.log('\n--- Health endpoint ---');
const resp = await page.request.get(`${TARGET_URL}/health`);
assert(resp.ok(), `Health endpoint returns 200 (status: ${resp.status()})`);
const body = await resp.json();
assert(body.status === 'ok', `Health response is {"status":"ok"} (got: ${JSON.stringify(body)})`);
});
```
**Step 2: Run test**
Run: `./tests/e2e/run.sh tests/e2e/test_health.js`
Expected: All PASS
**Step 3: Commit**
```bash
git add tests/e2e/test_health.js
git commit -m "test: add e2e health endpoint smoke test"
```
---
### Task 9: Run full suite and verify
**Step 1: Run the complete e2e suite**
Run: `./tests/e2e/run.sh`
Expected: All test files pass, server starts and stops cleanly.
**Step 2: Run the existing pytest suite to ensure no regressions**
Run: `uv run pytest -x -q`
Expected: All 120 tests pass.
**Step 3: Final commit (if any cleanup needed)**
---
## Future Enhancements (Not in Scope)
- **WebAuthn tests** — Playwright supports virtual authenticators via CDP. Could test registration and login with `cdpSession.send('WebAuthn.enable')`.
- **Dark mode tests** — Use `page.emulateMedia({ colorScheme: 'dark' })` to verify dark theme colors.
- **Visual regression** — Screenshot comparison with `expect(page).toHaveScreenshot()` (requires @playwright/test).
- **OIDC flow tests** — Once the OIDC provider is implemented, test authorization code flow end-to-end.