- 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.
491 lines
19 KiB
JavaScript
491 lines
19 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|