✅ 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:
parent
ac31be2d6d
commit
e6fb7735b1
3 changed files with 357 additions and 7 deletions
|
|
@ -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>
|
||||
|
|
|
|||
137
features/landing/frontend-public/e2e/helpers/age-gate.ts
Normal file
137
features/landing/frontend-public/e2e/helpers/age-gate.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue