/** * 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: '
Server Value
', 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); }); });