/** * login.spec.ts — Phase 19 AUTH-LOCAL-12/15/16 * * Real-login-form e2e tests covering the PWA login gate + form interaction. * * Strategy (Option C): * The global-setup seeds 'devuser'/'devpass' into local_credentials and the API's * devSessionCookieMiddleware issues a local-session cookie on every /api/* request * under DEV_AUTH_BYPASS=true. The OTHER specs (layout, calendar, lists) rely on that * cookie being present and do NOT clear it — they still reach the authed app unchanged. * * This spec runs in a SEPARATE browser context that clears the local-session cookie * (via storageState:'' and explicit cookie-clear) so the real login gate fires. After * verifying the form, it logs in as devuser/devpass to confirm the full round-trip. * * Specs covered: * 1. Navigating to the app while unauthenticated → redirected to /login, brand + form visible * 2. Wrong password → single "Incorrect username or password." error message * 3. Correct devuser/devpass → navigates into the app (out of /login) * * Only runs on the desktop/chromium project (Chromium handles local-session cookies * consistently; WebKit PWA restrictions are irrelevant here since the login form is * a normal web page, not a Home Screen PWA). Other profiles inherit the bypass cookie. * * Run: * pnpm --filter @familysync/pwa test:e2e --grep "login" * pnpm --filter @familysync/pwa exec playwright test --project=desktop login.spec.ts */ import { test, expect, type BrowserContext } from '@playwright/test'; // Selectors derived from 19-UI-SPEC.md Surfaces 3-7 (locked by plan 04 implementation) const SELECTORS = { usernameInput: '#login-username', // password input has id="login-password" (UI-SPEC Surface 5) passwordInput: '#login-password', // Primary submit: role=button with name "Sign in" (UI-SPEC Surface 7) submitBtn: 'button[type="submit"]', // Error message is in a role="status" element (UI-SPEC Surface 6) errorMessage: '[role="status"]', }; /** * Build an unauthenticated browser context by clearing all cookies and storage. * The devSessionCookieMiddleware issues a new local-session cookie on each API * request, so we need to clear the cookie from the BROWSER side. Navigating to * a page that clears the cookie header is the reliable approach in Playwright. */ async function makeUnauthContext( context: BrowserContext, baseURL: string, ): Promise { // Clear all cookies (removes the local-session cookie set by prior API calls) await context.clearCookies(); // Also clear localStorage/sessionStorage to avoid any cached auth state const page = await context.newPage(); try { // Navigate somewhere to gain origin access, then clear storage await page.goto(baseURL, { waitUntil: 'domcontentloaded', timeout: 10_000 }).catch(() => {}); await page.evaluate(() => { try { localStorage.clear(); } catch { /* cross-origin or unavailable */ } try { sessionStorage.clear(); } catch { /* cross-origin or unavailable */ } }); } finally { await page.close(); } } // Only run these specs on the desktop profile. The login form is a standard web // page (not PWA-specific) and Chromium handles cookies most consistently for this test. // iphone/pixel still reach the authed app via the bypass-issued cookie (unchanged behavior). test.describe('Login form — real auth round-trip (desktop/Chromium only)', () => { test.skip( ({ browserName }) => browserName !== 'chromium', 'Login form tests only run on Chromium (desktop profile) — other profiles use the bypass cookie', ); test('unauthenticated navigation → /login gate: brand slot and form visible', async ({ page, context, baseURL, }) => { // Start from a clean state — no local-session cookie await context.clearCookies(); // Navigate to the app root; the PWA login gate should redirect to /login await page.goto(baseURL ?? 'http://localhost:5173', { waitUntil: 'networkidle' }); // Assert we are on the /login route await expect(page).toHaveURL(/\/login/); // Brand slot: "FamilySync" text should be visible (UI-SPEC Surface 2) await expect(page.getByText('FamilySync', { exact: true })).toBeVisible(); // Login card heading "Sign in" (UI-SPEC Surface 3) await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible(); // Username field (UI-SPEC Surface 4) await expect(page.locator(SELECTORS.usernameInput)).toBeVisible(); // Password field (UI-SPEC Surface 5) await expect(page.locator(SELECTORS.passwordInput)).toBeVisible(); // Submit button (UI-SPEC Surface 7) await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible(); }); test('wrong password shows single "Incorrect username or password." error', async ({ page, context, }) => { await context.clearCookies(); await page.goto('/login', { waitUntil: 'domcontentloaded' }); // Fill in wrong credentials await page.locator(SELECTORS.usernameInput).fill('devuser'); await page.locator(SELECTORS.passwordInput).fill('wrongpassword'); await page.getByRole('button', { name: 'Sign in' }).click(); // Error message appears (UI-SPEC Surface 6 — "Incorrect username or password.") const errorEl = page.locator(SELECTORS.errorMessage); await expect(errorEl).toBeVisible({ timeout: 5_000 }); await expect(errorEl).toContainText('Incorrect username or password.'); // Still on /login await expect(page).toHaveURL(/\/login/); }); test('correct devuser/devpass logs in and navigates out of /login', async ({ page, context, }) => { await context.clearCookies(); await page.goto('/login', { waitUntil: 'domcontentloaded' }); // Fill in the seeded dev credentials (global-setup seeds devuser/devpass) await page.locator(SELECTORS.usernameInput).fill('devuser'); await page.locator(SELECTORS.passwordInput).fill('devpass'); await page.getByRole('button', { name: 'Sign in' }).click(); // After login, the page navigates away from /login (to / or /calendar) await expect(page).not.toHaveURL(/\/login/, { timeout: 10_000 }); }); });