Files
michaelschiemer/tests/e2e/livecomponents-error-recovery.spec.js
Michael Schiemer fc3d7e6357 feat(Production): Complete production deployment infrastructure
- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
2025-10-25 19:18:37 +02:00

614 lines
21 KiB
JavaScript

/**
* 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 = `
<button data-lc-action="submit">Submit</button>
<div class="livecomponent-error"></div>
`;
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);
});
});