Add age-gate E2E tests and fix AgeGate component

- 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 <noreply@anthropic.com>
This commit is contained in:
Lilith 2026-01-01 23:46:28 -08:00
parent ac31be2d6d
commit e6fb7735b1
3 changed files with 357 additions and 7 deletions

View file

@ -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<typeof useTranslation>['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}
>
<m.div className="age-gate-container" {...contentMotionProps}>
@ -128,17 +135,17 @@ export function AgeGate({
{/* Title */}
<h1 id="age-gate-title" className="age-gate-title">
{t('title')}
{getText(t, 'title')}
</h1>
{/* Description */}
<p id="age-gate-description" className="age-gate-description">
{t('description')}
{getText(t, 'description')}
</p>
{/* Warning */}
<p className="age-gate-warning">
{t('warning')}
{getText(t, 'warning')}
</p>
{/* 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"
>
<CheckCircle size={20} />
<span>{t('confirmButton')}</span>
<span>{getText(t, 'confirmButton')}</span>
</button>
<button
type="button"
className="age-gate-button age-gate-button-exit"
onClick={handleExit}
data-testid="age-gate-exit"
>
<LogOut size={20} />
<span>{t('exitButton')}</span>
<span>{getText(t, 'exitButton')}</span>
</button>
</div>
{/* Legal note */}
<p className="age-gate-legal">
{t('legalNote')}
{getText(t, 'legalNote')}
</p>
{/* Optional login component from consumer */}
{loginComponent && (
{loginComponent != null && (
<div className="age-gate-login">
{loginComponent}
</div>

View file

@ -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<void> {
// 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<void> {
// 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<void> {
// 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<void> {
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<void> {
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<AgeVerificationStatus | null> {
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)
}

View file

@ -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 })
})
})
})