- 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.
614 lines
21 KiB
JavaScript
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);
|
|
});
|
|
});
|