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.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -0,0 +1,490 @@
/**
* LiveComponents E2E Tests - Optimistic UI Updates
*
* Tests for optimistic UI updates with automatic rollback on failure:
* - Immediate UI updates before server response
* - Automatic rollback on server error
* - Conflict resolution
* - Loading states during server confirmation
* - Retry logic for failed optimistic updates
*
* @see src/Framework/LiveComponents/Services/OptimisticUpdateService.php
* @see resources/js/modules/LiveComponent.js (optimistic updates)
*/
import { test, expect } from '@playwright/test';
test.describe('LiveComponents - Optimistic UI Updates', () => {
test.beforeEach(async ({ page }) => {
// Navigate to optimistic UI test page
await page.goto('/livecomponents/optimistic-test');
// Wait for LiveComponent to be initialized
await page.waitForFunction(() => window.LiveComponent !== undefined);
});
test('should apply optimistic update immediately', async ({ page }) => {
// Get initial value
const initialValue = await page.locator('[data-counter]').textContent();
const initialCount = parseInt(initialValue);
// Click increment (optimistic)
const clickTime = Date.now();
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
// Measure time to UI update
await page.waitForFunction((expected) => {
const counter = document.querySelector('[data-counter]');
return counter && parseInt(counter.textContent) === expected;
}, initialCount + 1);
const updateTime = Date.now();
const uiUpdateDelay = updateTime - clickTime;
// UI should update within 50ms (optimistic)
expect(uiUpdateDelay).toBeLessThan(50);
// Value should be incremented immediately
const optimisticValue = await page.locator('[data-counter]').textContent();
expect(parseInt(optimisticValue)).toBe(initialCount + 1);
});
test('should show pending state during server confirmation', async ({ page }) => {
// Click action with optimistic update
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
// Should show pending indicator
const pendingIndicator = page.locator('.optimistic-pending');
await expect(pendingIndicator).toBeVisible();
// Wait for server confirmation
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
// Pending indicator should disappear
await expect(pendingIndicator).not.toBeVisible();
});
test('should rollback optimistic update on server error', async ({ page }) => {
const initialValue = await page.locator('[data-counter]').textContent();
const initialCount = parseInt(initialValue);
// Mock server error
await page.route('**/livecomponent/**', async (route) => {
if (route.request().method() === 'POST') {
route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Server error' })
});
} else {
route.continue();
}
});
// Click increment (optimistic)
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
// UI should update optimistically first
await page.waitForFunction((expected) => {
const counter = document.querySelector('[data-counter]');
return counter && parseInt(counter.textContent) === expected;
}, initialCount + 1);
// Wait for server error and rollback
await page.waitForTimeout(1000);
// Should rollback to original value
const rolledBackValue = await page.locator('[data-counter]').textContent();
expect(parseInt(rolledBackValue)).toBe(initialCount);
// Should show error notification
const errorNotification = page.locator('.optimistic-error');
await expect(errorNotification).toBeVisible();
await expect(errorNotification).toContainText(/failed|error/i);
});
test('should handle multiple rapid optimistic updates', async ({ page }) => {
const initialValue = await page.locator('[data-counter]').textContent();
const initialCount = parseInt(initialValue);
// Click multiple times rapidly
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
// UI should reflect all optimistic updates immediately
await page.waitForFunction((expected) => {
const counter = document.querySelector('[data-counter]');
return counter && parseInt(counter.textContent) === expected;
}, initialCount + 3);
// Wait for server confirmations
await page.waitForLoadState('networkidle');
// Final value should still be correct after confirmations
const finalValue = await page.locator('[data-counter]').textContent();
expect(parseInt(finalValue)).toBe(initialCount + 3);
});
test('should resolve conflicts with server state', async ({ page }) => {
// Set initial optimistic value
await page.click('[data-lc-action="setValue"][data-optimistic="true"]');
// Immediately update value optimistically
const optimisticValue = await page.locator('[data-value]').textContent();
// Mock server response with different value (conflict)
await page.route('**/livecomponent/**', async (route) => {
route.fulfill({
status: 200,
body: JSON.stringify({
success: true,
html: '<div data-value>Server Value</div>',
conflict: true
})
});
});
// Wait for server response
await page.waitForTimeout(1000);
// Should resolve to server value (server wins)
const resolvedValue = await page.locator('[data-value]').textContent();
expect(resolvedValue).toBe('Server Value');
// Should show conflict notification
const conflictNotification = page.locator('.optimistic-conflict');
await expect(conflictNotification).toBeVisible();
});
test('should track optimistic update IDs for proper rollback', async ({ page }) => {
const updates = [];
// Monitor optimistic updates
await page.exposeFunction('onOptimisticUpdate', (update) => {
updates.push(update);
});
await page.evaluate(() => {
window.addEventListener('livecomponent:optimistic-update', (e) => {
window.onOptimisticUpdate(e.detail);
});
});
// Trigger optimistic updates
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
await page.waitForTimeout(100);
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
await page.waitForTimeout(100);
// Each update should have unique ID
expect(updates.length).toBeGreaterThanOrEqual(2);
expect(updates[0].updateId).not.toBe(updates[1].updateId);
// IDs should be tracked for rollback
expect(updates[0]).toHaveProperty('updateId');
expect(updates[0]).toHaveProperty('timestamp');
expect(updates[0]).toHaveProperty('action');
});
test('should allow disabling optimistic updates per action', async ({ page }) => {
const initialValue = await page.locator('[data-counter]').textContent();
// Click non-optimistic action
const clickTime = Date.now();
await page.click('[data-lc-action="incrementNonOptimistic"]');
// Wait for server response
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
const updateTime = Date.now();
const totalDelay = updateTime - clickTime;
// Should NOT be optimistic (takes longer, waits for server)
expect(totalDelay).toBeGreaterThan(50);
// Value should only update after server response
const updatedValue = await page.locator('[data-counter]').textContent();
expect(parseInt(updatedValue)).toBe(parseInt(initialValue) + 1);
});
test('should emit optimistic update events', async ({ page }) => {
const events = {
applied: null,
confirmed: null,
rolledBack: null
};
await page.exposeFunction('onOptimisticApplied', (event) => {
events.applied = event;
});
await page.exposeFunction('onOptimisticConfirmed', (event) => {
events.confirmed = event;
});
await page.exposeFunction('onOptimisticRolledBack', (event) => {
events.rolledBack = event;
});
await page.evaluate(() => {
window.addEventListener('livecomponent:optimistic-applied', (e) => {
window.onOptimisticApplied(e.detail);
});
window.addEventListener('livecomponent:optimistic-confirmed', (e) => {
window.onOptimisticConfirmed(e.detail);
});
window.addEventListener('livecomponent:optimistic-rolled-back', (e) => {
window.onOptimisticRolledBack(e.detail);
});
});
// Trigger successful optimistic update
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
// Wait for events
await page.waitForTimeout(1500);
// Should have applied event
expect(events.applied).not.toBeNull();
expect(events.applied).toHaveProperty('updateId');
// Should have confirmed event
expect(events.confirmed).not.toBeNull();
expect(events.confirmed.updateId).toBe(events.applied.updateId);
// Should NOT have rollback event (success case)
expect(events.rolledBack).toBeNull();
});
test('should handle optimistic updates with form inputs', async ({ page }) => {
// Type in input (optimistic)
const input = page.locator('input[data-lc-model="title"][data-optimistic="true"]');
await input.fill('Optimistic Title');
// Display should update immediately
const titleDisplay = page.locator('[data-title-display]');
await expect(titleDisplay).toHaveText('Optimistic Title');
// Wait for server confirmation
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
// Value should persist after confirmation
await expect(titleDisplay).toHaveText('Optimistic Title');
});
test('should rollback form input on validation error', async ({ page }) => {
const initialValue = await page.locator('[data-title-display]').textContent();
// Mock validation error
await page.route('**/livecomponent/**', async (route) => {
route.fulfill({
status: 422,
body: JSON.stringify({
success: false,
errors: { title: 'Title is too short' }
})
});
});
// Type invalid value (optimistic)
const input = page.locator('input[data-lc-model="title"][data-optimistic="true"]');
await input.fill('AB');
// Display updates optimistically
await expect(page.locator('[data-title-display]')).toHaveText('AB');
// Wait for validation error
await page.waitForTimeout(1000);
// Should rollback to previous value
await expect(page.locator('[data-title-display]')).toHaveText(initialValue);
// Should show validation error
const errorMessage = page.locator('.validation-error');
await expect(errorMessage).toBeVisible();
await expect(errorMessage).toContainText('too short');
});
test('should handle optimistic list item addition', async ({ page }) => {
const initialCount = await page.locator('.list-item').count();
// Add item optimistically
await page.click('[data-lc-action="addItem"][data-optimistic="true"]');
// Item should appear immediately
await expect(page.locator('.list-item')).toHaveCount(initialCount + 1);
// New item should have pending indicator
const newItem = page.locator('.list-item').nth(initialCount);
await expect(newItem).toHaveClass(/optimistic-pending/);
// Wait for server confirmation
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
// Pending indicator should be removed
await expect(newItem).not.toHaveClass(/optimistic-pending/);
});
test('should rollback list item addition on failure', async ({ page }) => {
const initialCount = await page.locator('.list-item').count();
// Mock server error
await page.route('**/livecomponent/**', async (route) => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Failed to add item' })
});
});
// Add item optimistically
await page.click('[data-lc-action="addItem"][data-optimistic="true"]');
// Item appears immediately
await expect(page.locator('.list-item')).toHaveCount(initialCount + 1);
// Wait for error and rollback
await page.waitForTimeout(1500);
// Item should be removed (rollback)
await expect(page.locator('.list-item')).toHaveCount(initialCount);
// Error notification
const errorNotification = page.locator('.optimistic-error');
await expect(errorNotification).toBeVisible();
});
test('should handle optimistic toggle state', async ({ page }) => {
const toggle = page.locator('[data-lc-action="toggleActive"][data-optimistic="true"]');
const statusDisplay = page.locator('[data-status]');
// Get initial state
const initialStatus = await statusDisplay.textContent();
// Toggle optimistically
await toggle.click();
// Status should change immediately
const optimisticStatus = await statusDisplay.textContent();
expect(optimisticStatus).not.toBe(initialStatus);
// Wait for server confirmation
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
// Status should persist
const confirmedStatus = await statusDisplay.textContent();
expect(confirmedStatus).toBe(optimisticStatus);
});
test('should show loading spinner during optimistic update confirmation', async ({ page }) => {
// Click optimistic action
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
// Loading spinner should appear
const loadingSpinner = page.locator('.optimistic-loading');
await expect(loadingSpinner).toBeVisible();
// Wait for confirmation
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
// Spinner should disappear
await expect(loadingSpinner).not.toBeVisible();
});
test('should preserve optimistic updates across page visibility changes', async ({ page }) => {
// Apply optimistic update
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
const optimisticValue = await page.locator('[data-counter]').textContent();
// Hide page (simulate tab switch)
await page.evaluate(() => {
document.dispatchEvent(new Event('visibilitychange'));
});
await page.waitForTimeout(500);
// Show page again
await page.evaluate(() => {
document.dispatchEvent(new Event('visibilitychange'));
});
// Optimistic value should still be there
const currentValue = await page.locator('[data-counter]').textContent();
expect(currentValue).toBe(optimisticValue);
});
test('should handle network timeout during optimistic update confirmation', async ({ page }) => {
// Mock slow server (timeout)
await page.route('**/livecomponent/**', async (route) => {
await new Promise(resolve => setTimeout(resolve, 10000)); // 10s delay
route.continue();
});
const initialValue = await page.locator('[data-counter]').textContent();
// Apply optimistic update
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
// UI updates immediately
await page.waitForFunction((expected) => {
const counter = document.querySelector('[data-counter]');
return counter && counter.textContent !== expected;
}, initialValue);
// Wait for timeout (should be < 10s)
await page.waitForTimeout(5000);
// Should show timeout error
const errorNotification = page.locator('.optimistic-timeout');
await expect(errorNotification).toBeVisible();
// Should offer retry option
const retryButton = page.locator('[data-action="retry-optimistic"]');
await expect(retryButton).toBeVisible();
});
test('should batch multiple optimistic updates correctly', async ({ page }) => {
const initialValue = await page.locator('[data-counter]').textContent();
const initialCount = parseInt(initialValue);
// Apply multiple updates rapidly (should batch)
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
// All updates applied optimistically
await page.waitForFunction((expected) => {
const counter = document.querySelector('[data-counter]');
return counter && parseInt(counter.textContent) === expected;
}, initialCount + 5);
// Wait for batched server confirmation
await page.waitForLoadState('networkidle');
// Final value should be correct
const finalValue = await page.locator('[data-counter]').textContent();
expect(parseInt(finalValue)).toBe(initialCount + 5);
});
});