/** * LiveComponents E2E Tests - Error Recovery & Fallbacks * * Tests for robust error handling and graceful degradation: * - Network error recovery * - Server error handling with user feedback * - Graceful degradation when JavaScript fails * - Retry mechanisms with exponential backoff * - Offline mode detection and handling * - State recovery after errors * * @see src/Framework/LiveComponents/Services/ErrorRecoveryService.php * @see resources/js/modules/LiveComponent.js (error handling) */ import { test, expect } from '@playwright/test'; test.describe('LiveComponents - Error Recovery & Fallbacks', () => { test.beforeEach(async ({ page }) => { // Navigate to error recovery test page await page.goto('/livecomponents/error-test'); // Wait for LiveComponent to be initialized await page.waitForFunction(() => window.LiveComponent !== undefined); }); test('should show user-friendly error message on network failure', async ({ page }) => { // Simulate network failure await page.route('**/livecomponent/**', route => route.abort('failed')); // Trigger action await page.click('[data-lc-action="submit"]'); // Should show error notification const errorNotification = page.locator('.livecomponent-error'); await expect(errorNotification).toBeVisible(); await expect(errorNotification).toContainText(/network|connection|failed/i); // Should offer retry option const retryButton = page.locator('[data-action="retry"]'); await expect(retryButton).toBeVisible(); }); test('should automatically retry on transient network errors', async ({ page }) => { let attemptCount = 0; // Fail first 2 attempts, succeed on 3rd await page.route('**/livecomponent/**', route => { attemptCount++; if (attemptCount <= 2) { route.abort('failed'); } else { route.continue(); } }); // Trigger action await page.click('[data-lc-action="submit"]'); // Should eventually succeed after retries await page.waitForResponse(response => response.url().includes('/livecomponent') && response.status() === 200, { timeout: 10000 } ); // Success notification const successNotification = page.locator('.livecomponent-success'); await expect(successNotification).toBeVisible(); // Should have attempted 3 times expect(attemptCount).toBe(3); }); test('should use exponential backoff for retries', async ({ page }) => { const retryAttempts = []; await page.exposeFunction('onRetryAttempt', (attempt) => { retryAttempts.push(attempt); }); await page.evaluate(() => { window.addEventListener('livecomponent:retry-attempt', (e) => { window.onRetryAttempt({ timestamp: Date.now(), attemptNumber: e.detail.attemptNumber, delay: e.detail.delay }); }); }); // Simulate multiple failures await page.route('**/livecomponent/**', route => route.abort('failed')); // Trigger action await page.click('[data-lc-action="submit"]'); // Wait for multiple retry attempts await page.waitForTimeout(10000); // Should have multiple attempts with increasing delays expect(retryAttempts.length).toBeGreaterThan(1); // Delays should increase exponentially if (retryAttempts.length >= 3) { const delay1 = retryAttempts[1].delay; const delay2 = retryAttempts[2].delay; expect(delay2).toBeGreaterThan(delay1); } }); test('should stop retrying after max attempts', async ({ page }) => { let attemptCount = 0; // Always fail await page.route('**/livecomponent/**', route => { attemptCount++; route.abort('failed'); }); // Trigger action await page.click('[data-lc-action="submit"]'); // Wait for all retry attempts await page.waitForTimeout(15000); // Should stop after max attempts (typically 3-5) expect(attemptCount).toBeLessThanOrEqual(5); // Should show final error message const errorNotification = page.locator('.livecomponent-error'); await expect(errorNotification).toBeVisible(); await expect(errorNotification).toContainText(/unable|failed|try again later/i); }); test('should handle server 500 errors gracefully', async ({ page }) => { // Mock 500 error await page.route('**/livecomponent/**', route => { route.fulfill({ status: 500, body: JSON.stringify({ error: 'Internal Server Error' }) }); }); // Trigger action await page.click('[data-lc-action="submit"]'); // Should show server error message const errorNotification = page.locator('.livecomponent-error'); await expect(errorNotification).toBeVisible(); await expect(errorNotification).toContainText(/server error|something went wrong/i); // Should log error for debugging const consoleLogs = []; page.on('console', msg => { if (msg.type() === 'error') { consoleLogs.push(msg.text()); } }); await page.waitForTimeout(500); // Should have logged error details expect(consoleLogs.some(log => log.includes('500'))).toBe(true); }); test('should handle validation errors with field-specific feedback', async ({ page }) => { // Mock validation errors await page.route('**/livecomponent/**', route => { route.fulfill({ status: 422, body: JSON.stringify({ success: false, errors: { email: 'Email is invalid', password: 'Password must be at least 8 characters' } }) }); }); // Trigger form submission await page.click('[data-lc-action="submit"]'); // Wait for validation errors await page.waitForTimeout(1000); // Should show field-specific errors const emailError = page.locator('.validation-error[data-field="email"]'); await expect(emailError).toBeVisible(); await expect(emailError).toContainText('Email is invalid'); const passwordError = page.locator('.validation-error[data-field="password"]'); await expect(passwordError).toBeVisible(); await expect(passwordError).toContainText('at least 8 characters'); // Form should still be editable const emailInput = page.locator('input[name="email"]'); await expect(emailInput).toBeEnabled(); }); test('should detect offline mode and queue actions', async ({ page }) => { // Simulate going offline await page.context().setOffline(true); // Trigger action while offline await page.click('[data-lc-action="submit"]'); // Should show offline notification const offlineNotification = page.locator('.livecomponent-offline'); await expect(offlineNotification).toBeVisible(); await expect(offlineNotification).toContainText(/offline|no connection/i); // Action should be queued const queuedActions = await page.evaluate(() => { return window.LiveComponent.getQueuedActions(); }); expect(queuedActions.length).toBeGreaterThan(0); // Go back online await page.context().setOffline(false); // Wait for auto-retry await page.waitForResponse(response => response.url().includes('/livecomponent'), { timeout: 10000 } ); // Queued actions should be executed const remainingActions = await page.evaluate(() => { return window.LiveComponent.getQueuedActions(); }); expect(remainingActions.length).toBe(0); }); test('should preserve component state after error recovery', async ({ page }) => { // Fill form await page.fill('input[name="title"]', 'Test Title'); await page.fill('input[name="description"]', 'Test Description'); // Mock temporary error let failOnce = true; await page.route('**/livecomponent/**', route => { if (failOnce) { failOnce = false; route.abort('failed'); } else { route.continue(); } }); // Trigger action await page.click('[data-lc-action="submit"]'); // Wait for retry and success await page.waitForResponse(response => response.url().includes('/livecomponent') && response.status() === 200, { timeout: 10000 } ); // Form values should be preserved await expect(page.locator('input[name="title"]')).toHaveValue('Test Title'); await expect(page.locator('input[name="description"]')).toHaveValue('Test Description'); }); test('should fallback to full page reload on critical JavaScript error', async ({ page }) => { // Inject JavaScript error in LiveComponent await page.evaluate(() => { const originalHandle = window.LiveComponent.handleAction; window.LiveComponent.handleAction = function() { throw new Error('Critical JavaScript error'); }; }); // Track page reload let pageReloaded = false; page.on('load', () => { pageReloaded = true; }); // Trigger action await page.click('[data-lc-action="submit"]'); // Wait for fallback await page.waitForTimeout(3000); // Should fallback to traditional form submission or page reload // (depending on framework implementation) const errorFallback = page.locator('.livecomponent-fallback'); const hasErrorFallback = await errorFallback.isVisible().catch(() => false); // Either fallback indicator or page reload should occur expect(hasErrorFallback || pageReloaded).toBe(true); }); test('should handle timeout errors with user feedback', async ({ page }) => { // Mock slow server (timeout) await page.route('**/livecomponent/**', async route => { await new Promise(resolve => setTimeout(resolve, 10000)); route.continue(); }); // Trigger action await page.click('[data-lc-action="submit"]'); // Should show timeout error (before 10s) const timeoutError = page.locator('.livecomponent-timeout'); await expect(timeoutError).toBeVisible({ timeout: 6000 }); await expect(timeoutError).toContainText(/timeout|taking too long/i); // Should offer retry or cancel const retryButton = page.locator('[data-action="retry"]'); const cancelButton = page.locator('[data-action="cancel"]'); await expect(retryButton).toBeVisible(); await expect(cancelButton).toBeVisible(); }); test('should allow manual retry after error', async ({ page }) => { // Mock error let shouldFail = true; await page.route('**/livecomponent/**', route => { if (shouldFail) { route.abort('failed'); } else { route.continue(); } }); // Trigger action (will fail) await page.click('[data-lc-action="submit"]'); // Wait for error await expect(page.locator('.livecomponent-error')).toBeVisible(); // Fix the error condition shouldFail = false; // Click retry button await page.click('[data-action="retry"]'); // Should succeed on retry await page.waitForResponse(response => response.url().includes('/livecomponent') && response.status() === 200 ); const successNotification = page.locator('.livecomponent-success'); await expect(successNotification).toBeVisible(); }); test('should handle CSRF token expiration', async ({ page }) => { // Mock CSRF token error await page.route('**/livecomponent/**', route => { route.fulfill({ status: 403, body: JSON.stringify({ error: 'CSRF token mismatch' }) }); }); // Trigger action await page.click('[data-lc-action="submit"]'); // Should detect CSRF error const csrfError = page.locator('.livecomponent-csrf-error'); await expect(csrfError).toBeVisible(); // Should automatically refresh CSRF token and retry // (framework-specific implementation) await expect(csrfError).toContainText(/security|session|refresh/i); }); test('should show loading state during error recovery', async ({ page }) => { let attemptCount = 0; await page.route('**/livecomponent/**', route => { attemptCount++; if (attemptCount <= 2) { setTimeout(() => route.abort('failed'), 500); } else { route.continue(); } }); // Trigger action await page.click('[data-lc-action="submit"]'); // Loading indicator should persist during retries const loadingIndicator = page.locator('.livecomponent-loading'); await expect(loadingIndicator).toBeVisible(); // Wait for successful retry await page.waitForResponse(response => response.url().includes('/livecomponent') && response.status() === 200, { timeout: 15000 } ); // Loading should disappear await expect(loadingIndicator).not.toBeVisible(); }); test('should emit error events for monitoring', async ({ page }) => { const errorEvents = []; await page.exposeFunction('onErrorEvent', (event) => { errorEvents.push(event); }); await page.evaluate(() => { window.addEventListener('livecomponent:error', (e) => { window.onErrorEvent(e.detail); }); }); // Trigger error await page.route('**/livecomponent/**', route => route.abort('failed')); await page.click('[data-lc-action="submit"]'); // Wait for error events await page.waitForTimeout(2000); // Should have error events expect(errorEvents.length).toBeGreaterThan(0); expect(errorEvents[0]).toHaveProperty('type'); expect(errorEvents[0]).toHaveProperty('message'); expect(errorEvents[0]).toHaveProperty('timestamp'); }); test('should recover from partial response errors', async ({ page }) => { // Mock malformed JSON response await page.route('**/livecomponent/**', route => { route.fulfill({ status: 200, body: '{invalid json' }); }); // Trigger action await page.click('[data-lc-action="submit"]'); // Should handle parse error gracefully const errorNotification = page.locator('.livecomponent-error'); await expect(errorNotification).toBeVisible(); await expect(errorNotification).toContainText(/error|failed/i); // Component should remain functional const component = page.locator('[data-component-id]'); await expect(component).toBeVisible(); }); test('should handle rate limiting with backoff', async ({ page }) => { // Mock rate limit error await page.route('**/livecomponent/**', route => { route.fulfill({ status: 429, headers: { 'Retry-After': '5' }, body: JSON.stringify({ error: 'Too many requests' }) }); }); // Trigger action await page.click('[data-lc-action="submit"]'); // Should show rate limit message const rateLimitNotification = page.locator('.livecomponent-rate-limit'); await expect(rateLimitNotification).toBeVisible(); await expect(rateLimitNotification).toContainText(/too many|rate limit|try again/i); // Should show countdown timer const countdown = page.locator('.retry-countdown'); await expect(countdown).toBeVisible(); await expect(countdown).toContainText(/\d+ second/); }); test('should clear errors when action succeeds', async ({ page }) => { // First: trigger error let shouldFail = true; await page.route('**/livecomponent/**', route => { if (shouldFail) { route.abort('failed'); } else { route.continue(); } }); await page.click('[data-lc-action="submit"]'); await expect(page.locator('.livecomponent-error')).toBeVisible(); // Second: fix and retry shouldFail = false; await page.click('[data-action="retry"]'); await page.waitForResponse(response => response.url().includes('/livecomponent') && response.status() === 200 ); // Error should be cleared await expect(page.locator('.livecomponent-error')).not.toBeVisible(); // Success message should appear const successNotification = page.locator('.livecomponent-success'); await expect(successNotification).toBeVisible(); }); test('should handle concurrent action errors independently', async ({ page }) => { // Create two components await page.evaluate(() => { const container = document.querySelector('#component-container'); ['component-1', 'component-2'].forEach(id => { const component = document.createElement('div'); component.dataset.componentId = id; component.innerHTML = `
`; container.appendChild(component); }); }); // Mock different responses await page.route('**/livecomponent/**', route => { const url = route.request().url(); if (url.includes('component-1')) { route.abort('failed'); // Fail component-1 } else { route.continue(); // Succeed component-2 } }); // Trigger both actions await page.click('#component-1 [data-lc-action="submit"]'); await page.click('#component-2 [data-lc-action="submit"]'); await page.waitForTimeout(2000); // Component 1 should show error await expect(page.locator('#component-1 .livecomponent-error')).toBeVisible(); // Component 2 should succeed (no error) await expect(page.locator('#component-2 .livecomponent-error')).not.toBeVisible(); }); test('should provide debug information in development mode', async ({ page }) => { // Set development mode await page.evaluate(() => { document.documentElement.dataset.env = 'development'; }); // Mock error await page.route('**/livecomponent/**', route => { route.fulfill({ status: 500, body: JSON.stringify({ error: 'Database connection failed', debug: { file: '/var/www/src/Database/Connection.php', line: 42, trace: ['DatabaseException', 'ConnectionPool', 'EntityManager'] } }) }); }); // Trigger action await page.click('[data-lc-action="submit"]'); // Should show debug details in dev mode const debugInfo = page.locator('.livecomponent-debug'); await expect(debugInfo).toBeVisible(); await expect(debugInfo).toContainText('Connection.php:42'); await expect(debugInfo).toContainText('Database connection failed'); }); test('should hide sensitive debug info in production mode', async ({ page }) => { // Set production mode await page.evaluate(() => { document.documentElement.dataset.env = 'production'; }); // Mock error with debug info await page.route('**/livecomponent/**', route => { route.fulfill({ status: 500, body: JSON.stringify({ error: 'Internal error', debug: { file: '/var/www/src/Database/Connection.php', line: 42, sensitive_data: 'password=secret123' } }) }); }); // Trigger action await page.click('[data-lc-action="submit"]'); // Should show generic error only const errorNotification = page.locator('.livecomponent-error'); await expect(errorNotification).toBeVisible(); await expect(errorNotification).not.toContainText('Connection.php'); await expect(errorNotification).not.toContainText('password'); // Generic message only await expect(errorNotification).toContainText(/something went wrong|error occurred/i); }); });