From e6fb7735b129de43e595404f16eda17adbc93bff Mon Sep 17 00:00:00 2001 From: Lilith Date: Thu, 1 Jan 2026 23:46:28 -0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20Add=20age-gate=20E2E=20tests=20and?= =?UTF-8?q?=20fix=20AgeGate=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add age-gate-flow.spec.ts for verification flows - Add age-gate.ts helper for test utilities - Fix AgeGate.tsx component issues 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/components/AgeGate/AgeGate.tsx | 23 +- .../frontend-public/e2e/helpers/age-gate.ts | 137 ++++++++++++ .../e2e/tests/age-gate/age-gate-flow.spec.ts | 204 ++++++++++++++++++ 3 files changed, 357 insertions(+), 7 deletions(-) create mode 100644 features/landing/frontend-public/e2e/helpers/age-gate.ts create mode 100644 features/landing/frontend-public/e2e/tests/age-gate/age-gate-flow.spec.ts diff --git a/features/age-verification/frontend-components/src/components/AgeGate/AgeGate.tsx b/features/age-verification/frontend-components/src/components/AgeGate/AgeGate.tsx index bf4a79c76..b4666e3c5 100644 --- a/features/age-verification/frontend-components/src/components/AgeGate/AgeGate.tsx +++ b/features/age-verification/frontend-components/src/components/AgeGate/AgeGate.tsx @@ -14,6 +14,12 @@ import { DEFAULT_EXIT_URL } from '@lilith/age-verification' import './AgeGate.css' +/** Type-safe translation wrapper that always returns string */ +const getText = (t: ReturnType['t'], key: string): string => { + const result = t(key) + return typeof result === 'string' ? result : String(result) +} + export interface AgeGateProps { /** Whether the gate is currently shown */ isOpen: boolean @@ -118,6 +124,7 @@ export function AgeGate({ aria-modal="true" aria-labelledby="age-gate-title" aria-describedby="age-gate-description" + data-testid="age-gate" {...motionProps} > @@ -128,17 +135,17 @@ export function AgeGate({ {/* Title */}

- {t('title')} + {getText(t, 'title')}

{/* Description */}

- {t('description')} + {getText(t, 'description')}

{/* Warning */}

- {t('warning')} + {getText(t, 'warning')}

{/* Actions */} @@ -148,28 +155,30 @@ export function AgeGate({ type="button" className="age-gate-button age-gate-button-confirm" onClick={onConfirm} + data-testid="age-gate-confirm" > - {t('confirmButton')} + {getText(t, 'confirmButton')} {/* Legal note */}

- {t('legalNote')} + {getText(t, 'legalNote')}

{/* Optional login component from consumer */} - {loginComponent && ( + {loginComponent != null && (
{loginComponent}
diff --git a/features/landing/frontend-public/e2e/helpers/age-gate.ts b/features/landing/frontend-public/e2e/helpers/age-gate.ts new file mode 100644 index 000000000..4c4cbeb9f --- /dev/null +++ b/features/landing/frontend-public/e2e/helpers/age-gate.ts @@ -0,0 +1,137 @@ +/** + * Age Gate E2E Helpers + * + * Provides utilities for handling age verification in E2E tests. + * Most tests should use bypassAgeGate() to skip the gate and test actual functionality. + * The age-gate-flow.spec.ts tests the gate itself. + * + * @module helpers/age-gate + */ + +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +/** + * LocalStorage key for age verification + * Must match: features/age-verification/shared/src/constants/index.ts + */ +const AGE_VERIFICATION_STORAGE_KEY = 'lilith-age-verified' + +/** + * Age verification status that mimics successful verification + */ +interface AgeVerificationStatus { + isVerified: boolean + method: 'self-declaration' | null + tier: 0 | 1 | 2 | 3 | 4 + verifiedAt: string | null +} + +/** + * Bypass age gate by setting localStorage before navigation + * + * Use this in beforeEach for tests that need to test app functionality + * without interacting with the age gate. + * + * @param page - Playwright page object + * @example + * test.beforeEach(async ({ page }) => { + * await bypassAgeGate(page) + * await page.goto('/') + * }) + */ +export async function bypassAgeGate(page: Page): Promise { + // Use addInitScript to set localStorage BEFORE page loads + // This runs before any page scripts and avoids the age gate flash + await page.addInitScript((key) => { + const verificationStatus = { + isVerified: true, + method: 'self-declaration', + tier: 1, + verifiedAt: new Date().toISOString(), + } + localStorage.setItem(key, JSON.stringify(verificationStatus)) + }, AGE_VERIFICATION_STORAGE_KEY) +} + +/** + * Clear age verification from localStorage + * + * Use this to reset to unverified state for testing age gate behavior. + * Must be called AFTER navigating to a page (not about:blank). + * + * @param page - Playwright page object + */ +export async function clearAgeVerification(page: Page): Promise { + // Use addInitScript to clear localStorage BEFORE page loads + // This ensures the age gate shows on navigation + await page.addInitScript((key) => { + localStorage.removeItem(key) + }, AGE_VERIFICATION_STORAGE_KEY) +} + +/** + * Verify through the age gate by clicking the confirm button + * + * Use this when you need to test the actual age gate flow. + * + * @param page - Playwright page object + * @example + * test('should verify through age gate', async ({ page }) => { + * await clearAgeVerification(page) + * await page.goto('/') + * await verifyThroughAgeGate(page) + * // Now app content is visible + * }) + */ +export async function verifyThroughAgeGate(page: Page): Promise { + // Wait for age gate modal to appear + const ageGate = page.getByTestId('age-gate') + await expect(ageGate).toBeVisible({ timeout: 10000 }) + + // Click the confirm button + const confirmButton = page.getByTestId('age-gate-confirm') + await expect(confirmButton).toBeVisible() + await confirmButton.click() + + // Wait for age gate to close + await expect(ageGate).not.toBeVisible({ timeout: 5000 }) +} + +/** + * Assert that age gate is currently visible + * + * @param page - Playwright page object + */ +export async function assertAgeGateVisible(page: Page): Promise { + const ageGate = page.getByTestId('age-gate') + await expect(ageGate).toBeVisible() +} + +/** + * Assert that age gate is not visible (app content should be) + * + * @param page - Playwright page object + */ +export async function assertAgeGateNotVisible(page: Page): Promise { + const ageGate = page.getByTestId('age-gate') + await expect(ageGate).not.toBeVisible() +} + +/** + * Get the current age verification status from localStorage + * + * @param page - Playwright page object + * @returns The stored verification status or null if not verified + */ +export async function getAgeVerificationStatus(page: Page): Promise { + return page.evaluate((key) => { + const stored = localStorage.getItem(key) + if (!stored) return null + try { + return JSON.parse(stored) + } catch { + return null + } + }, AGE_VERIFICATION_STORAGE_KEY) +} diff --git a/features/landing/frontend-public/e2e/tests/age-gate/age-gate-flow.spec.ts b/features/landing/frontend-public/e2e/tests/age-gate/age-gate-flow.spec.ts new file mode 100644 index 000000000..bef74becf --- /dev/null +++ b/features/landing/frontend-public/e2e/tests/age-gate/age-gate-flow.spec.ts @@ -0,0 +1,204 @@ +/** + * E2E Tests for Age Gate Flow + * + * Tests the age verification gate that blocks content until user confirms age. + * This is the primary test for the age gate - other tests should use bypassAgeGate(). + * + * Key scenarios: + * - Gate appears on first visit + * - Confirmation flow works + * - Persistence across sessions + * - App content renders after verification + */ + +import { test, expect } from '@playwright/test' +import { + bypassAgeGate, + clearAgeVerification, + verifyThroughAgeGate, + assertAgeGateVisible, + assertAgeGateNotVisible, + getAgeVerificationStatus, +} from '../../helpers' + +test.describe('Age Gate Flow', () => { + test.describe('First Visit (No Verification)', () => { + test.beforeEach(async ({ page }) => { + // Ensure no verification exists by clearing on page load + await clearAgeVerification(page) + }) + + test('should show age gate on first visit', async ({ page }) => { + await page.goto('/') + + // Age gate should be visible + await assertAgeGateVisible(page) + + // Verify key elements are present + await expect(page.getByTestId('age-gate-confirm')).toBeVisible() + await expect(page.getByTestId('age-gate-exit')).toBeVisible() + + // Title should be visible + await expect(page.locator('#age-gate-title')).toBeVisible() + }) + + test('should block app content when age gate is visible', async ({ page }) => { + await page.goto('/') + + // Age gate should be visible + await assertAgeGateVisible(page) + + // App content should NOT be visible (FloatingSettings is a good indicator) + await expect(page.getByTestId('floating-settings-button')).not.toBeVisible() + }) + + test('should verify and show app content on confirm click', async ({ page }) => { + await page.goto('/') + + // Verify through the gate + await verifyThroughAgeGate(page) + + // App content should now be visible + await expect(page.getByTestId('floating-settings-button')).toBeVisible({ timeout: 10000 }) + + // Age gate should be hidden + await assertAgeGateNotVisible(page) + }) + + test('should persist verification to localStorage', async ({ page }) => { + await page.goto('/') + + // Verify through the gate + await verifyThroughAgeGate(page) + + // Check localStorage + const status = await getAgeVerificationStatus(page) + expect(status).not.toBeNull() + expect(status?.isVerified).toBe(true) + expect(status?.method).toBe('self-declaration') + expect(status?.tier).toBe(1) + expect(status?.verifiedAt).toBeTruthy() + }) + }) + + test.describe('Returning User (Already Verified)', () => { + test.beforeEach(async ({ page }) => { + // Set up verification before navigating + await bypassAgeGate(page) + }) + + test('should not show age gate for verified users', async ({ page }) => { + await page.goto('/') + + // Wait for app to load + await page.waitForLoadState('networkidle') + + // Age gate should NOT be visible + await assertAgeGateNotVisible(page) + + // App content should be visible + await expect(page.getByTestId('floating-settings-button')).toBeVisible({ timeout: 10000 }) + }) + + test('should maintain verification after page reload', async ({ page }) => { + await page.goto('/') + + // Reload page + await page.reload() + await page.waitForLoadState('networkidle') + + // Age gate should still NOT be visible + await assertAgeGateNotVisible(page) + + // App content should still be visible + await expect(page.getByTestId('floating-settings-button')).toBeVisible({ timeout: 10000 }) + }) + + test('should maintain verification across navigation', async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Navigate to another page + await page.goto('/shop') + await page.waitForLoadState('networkidle') + + // Age gate should NOT appear + await assertAgeGateNotVisible(page) + }) + }) + + test.describe('Post-Verification App Functionality', () => { + test.beforeEach(async ({ page }) => { + // Use verification through gate to test full flow + await clearAgeVerification(page) + await page.goto('/') + await verifyThroughAgeGate(page) + }) + + test('should render FloatingSettings after verification', async ({ page }) => { + // FloatingSettings should be visible and functional + const settingsButton = page.getByTestId('floating-settings-button') + await expect(settingsButton).toBeVisible() + + // Should be clickable + await settingsButton.click() + + // Settings modal/panel should appear + await expect(page.getByTestId('settings-modal')).toBeVisible({ timeout: 5000 }) + }) + + test('should have no console errors after verification', async ({ page }) => { + const errors: string[] = [] + + // Collect console errors + page.on('console', (msg) => { + if (msg.type() === 'error') { + errors.push(msg.text()) + } + }) + + // Wait a bit for any async errors + await page.waitForTimeout(2000) + + // Check for app content + await expect(page.getByTestId('floating-settings-button')).toBeVisible() + + // Filter out known acceptable errors (e.g., MSW info messages) + const criticalErrors = errors.filter( + (e) => !e.includes('[MSW]') && !e.includes('favicon') + ) + + expect(criticalErrors).toHaveLength(0) + }) + + test('should allow navigation to all pages after verification', async ({ page }) => { + // Verification already done in beforeEach, app content should be visible + // Use client-side navigation to stay within same context + await page.getByRole('link', { name: /shop/i }).first().click() + await page.waitForLoadState('networkidle') + + // App should still be visible (not showing age gate) + await expect(page.getByTestId('floating-settings-button')).toBeVisible({ timeout: 5000 }) + }) + }) + + test.describe('Keyboard Navigation', () => { + test.beforeEach(async ({ page }) => { + await clearAgeVerification(page) + }) + + test('should verify on Enter key press', async ({ page }) => { + await page.goto('/') + + // Wait for age gate + await assertAgeGateVisible(page) + + // Press Enter to confirm + await page.keyboard.press('Enter') + + // Should be verified + await assertAgeGateNotVisible(page) + await expect(page.getByTestId('floating-settings-button')).toBeVisible({ timeout: 10000 }) + }) + }) +})