/** * LiveComponents E2E Tests - Request Batching * * Tests for automatic request batching functionality: * - Multiple actions batched into single HTTP request * - Batch payload structure and response handling * - Error handling for failed actions in batch * - Batch size limits and automatic debouncing * - Network efficiency validation * * @see src/Framework/LiveComponents/Services/BatchRequestHandler.php * @see resources/js/modules/LiveComponent.js (batch handling) */ import { test, expect } from '@playwright/test'; test.describe('LiveComponents - Request Batching', () => { test.beforeEach(async ({ page }) => { // Navigate to test page with LiveComponent await page.goto('/livecomponents/test'); // Wait for LiveComponent to be initialized await page.waitForFunction(() => window.LiveComponent !== undefined); }); test('should batch multiple rapid actions into single request', async ({ page }) => { // Track network requests const requests = []; page.on('request', request => { if (request.url().includes('/livecomponent')) { requests.push(request); } }); // Trigger multiple actions rapidly (should be batched) await page.click('[data-lc-action="increment"]'); await page.click('[data-lc-action="increment"]'); await page.click('[data-lc-action="increment"]'); // Wait for batch to be sent (debounced) await page.waitForTimeout(100); // Should only have 1 request (batched) expect(requests.length).toBe(1); // Verify batch request structure const request = requests[0]; const postData = request.postData(); expect(postData).toBeTruthy(); const payload = JSON.parse(postData); expect(payload).toHaveProperty('batch'); expect(payload.batch).toBeInstanceOf(Array); expect(payload.batch.length).toBe(3); }); test('should handle batch response with multiple action results', async ({ page }) => { // Trigger batched actions await page.click('[data-lc-action="increment"]'); await page.click('[data-lc-action="updateTitle"]'); await page.click('[data-lc-action="toggleFlag"]'); // Wait for batch response const response = await page.waitForResponse(response => response.url().includes('/livecomponent') && response.status() === 200 ); const responseData = await response.json(); // Verify batch response structure expect(responseData).toHaveProperty('batch_results'); expect(responseData.batch_results).toBeInstanceOf(Array); expect(responseData.batch_results.length).toBe(3); // Verify each result has required fields responseData.batch_results.forEach(result => { expect(result).toHaveProperty('success'); expect(result).toHaveProperty('html'); expect(result).toHaveProperty('action_index'); }); // Verify component state was updated correctly const counter = await page.locator('.counter-value').textContent(); expect(parseInt(counter)).toBeGreaterThan(0); }); test('should handle partial batch failure gracefully', async ({ page }) => { // Trigger batch with one failing action await page.click('[data-lc-action="increment"]'); await page.click('[data-lc-action="failingAction"]'); // This will fail await page.click('[data-lc-action="updateTitle"]'); await page.waitForTimeout(100); const response = await page.waitForResponse(response => response.url().includes('/livecomponent') ); const responseData = await response.json(); // First action should succeed expect(responseData.batch_results[0].success).toBe(true); // Second action should fail expect(responseData.batch_results[1].success).toBe(false); expect(responseData.batch_results[1]).toHaveProperty('error'); // Third action should still execute expect(responseData.batch_results[2].success).toBe(true); // Verify error notification was shown const errorNotification = page.locator('.livecomponent-error'); await expect(errorNotification).toBeVisible(); }); test('should respect batch size limits', async ({ page }) => { const requests = []; page.on('request', request => { if (request.url().includes('/livecomponent')) { requests.push(request); } }); // Trigger more actions than batch limit (default: 10) for (let i = 0; i < 15; i++) { await page.click('[data-lc-action="increment"]'); } await page.waitForTimeout(200); // Should have 2 requests (batch size limit: 10 + 5) expect(requests.length).toBeGreaterThanOrEqual(2); // First batch should have 10 actions const firstBatchData = JSON.parse(requests[0].postData()); expect(firstBatchData.batch.length).toBeLessThanOrEqual(10); // Second batch should have remaining actions const secondBatchData = JSON.parse(requests[1].postData()); expect(secondBatchData.batch.length).toBeLessThanOrEqual(5); }); test('should debounce rapid actions before batching', async ({ page }) => { const requests = []; page.on('request', request => { if (request.url().includes('/livecomponent')) { requests.push(request); } }); // Click rapidly within debounce window (default: 50ms) await page.click('[data-lc-action="increment"]'); await page.waitForTimeout(10); await page.click('[data-lc-action="increment"]'); await page.waitForTimeout(10); await page.click('[data-lc-action="increment"]'); // Wait for debounce + network await page.waitForTimeout(150); // Should only have 1 batched request expect(requests.length).toBe(1); // Click after debounce window expires await page.waitForTimeout(100); await page.click('[data-lc-action="increment"]'); await page.waitForTimeout(150); // Should have 2 requests total expect(requests.length).toBe(2); }); test('should send immediate request when batch is manually flushed', async ({ page }) => { const requests = []; page.on('request', request => { if (request.url().includes('/livecomponent')) { requests.push(request); } }); // Trigger action await page.click('[data-lc-action="increment"]'); // Manually flush batch before debounce expires await page.evaluate(() => { window.LiveComponent.flushBatch(); }); // Wait for immediate request await page.waitForTimeout(50); // Should have sent request immediately expect(requests.length).toBe(1); }); test('should validate network efficiency with batching', async ({ page }) => { // Count requests WITHOUT batching await page.evaluate(() => { window.LiveComponent.disableBatching(); }); const unbatchedRequests = []; page.on('request', request => { if (request.url().includes('/livecomponent')) { unbatchedRequests.push(request); } }); // Trigger 5 actions for (let i = 0; i < 5; i++) { await page.click('[data-lc-action="increment"]'); await page.waitForTimeout(20); } await page.waitForTimeout(200); const unbatchedCount = unbatchedRequests.length; expect(unbatchedCount).toBe(5); // Should be 5 separate requests // Reload and test WITH batching await page.reload(); await page.waitForFunction(() => window.LiveComponent !== undefined); const batchedRequests = []; page.on('request', request => { if (request.url().includes('/livecomponent')) { batchedRequests.push(request); } }); // Trigger same 5 actions for (let i = 0; i < 5; i++) { await page.click('[data-lc-action="increment"]'); } await page.waitForTimeout(200); const batchedCount = batchedRequests.length; expect(batchedCount).toBe(1); // Should be 1 batched request // Network efficiency: 80% reduction in requests const efficiency = ((unbatchedCount - batchedCount) / unbatchedCount) * 100; expect(efficiency).toBeGreaterThanOrEqual(80); }); test('should preserve action order in batch', async ({ page }) => { // Trigger actions in specific order await page.click('[data-lc-action="setValueA"]'); await page.click('[data-lc-action="setValueB"]'); await page.click('[data-lc-action="setValueC"]'); const response = await page.waitForResponse(response => response.url().includes('/livecomponent') && response.status() === 200 ); const responseData = await response.json(); // Verify action_index preserves order expect(responseData.batch_results[0].action_index).toBe(0); expect(responseData.batch_results[1].action_index).toBe(1); expect(responseData.batch_results[2].action_index).toBe(2); // Verify final state reflects correct order const finalValue = await page.locator('.value-display').textContent(); expect(finalValue).toBe('C'); // Last action wins }); test('should include component state in batch request', async ({ page }) => { const requests = []; page.on('request', request => { if (request.url().includes('/livecomponent')) { requests.push(request); } }); // Update state and trigger batch await page.fill('[data-lc-model="searchTerm"]', 'test query'); await page.click('[data-lc-action="search"]'); await page.click('[data-lc-action="filter"]'); await page.waitForTimeout(100); const request = requests[0]; const payload = JSON.parse(request.postData()); // Verify state is included expect(payload).toHaveProperty('state'); expect(payload.state.searchTerm).toBe('test query'); // Verify batch actions expect(payload.batch.length).toBe(2); expect(payload.batch[0].action).toBe('search'); expect(payload.batch[1].action).toBe('filter'); }); test('should handle batch with different action parameters', async ({ page }) => { // Trigger actions with different params await page.evaluate(() => { const componentId = document.querySelector('[data-component-id]').dataset.componentId; window.LiveComponent.executeAction(componentId, 'updateField', { field: 'name', value: 'John' }); window.LiveComponent.executeAction(componentId, 'updateField', { field: 'email', value: 'john@example.com' }); window.LiveComponent.executeAction(componentId, 'updateField', { field: 'age', value: 30 }); }); const response = await page.waitForResponse(response => response.url().includes('/livecomponent') && response.status() === 200 ); const responseData = await response.json(); // Verify all 3 actions were batched expect(responseData.batch_results.length).toBe(3); // Verify each action maintained its parameters expect(responseData.batch_results[0].success).toBe(true); expect(responseData.batch_results[1].success).toBe(true); expect(responseData.batch_results[2].success).toBe(true); // Verify final component state const nameField = await page.locator('[data-field="name"]').textContent(); const emailField = await page.locator('[data-field="email"]').textContent(); const ageField = await page.locator('[data-field="age"]').textContent(); expect(nameField).toBe('John'); expect(emailField).toBe('john@example.com'); expect(ageField).toBe('30'); }); test('should emit batch events for monitoring', async ({ page }) => { let batchStartEvent = null; let batchCompleteEvent = null; // Listen for batch events await page.exposeFunction('onBatchStart', (event) => { batchStartEvent = event; }); await page.exposeFunction('onBatchComplete', (event) => { batchCompleteEvent = event; }); await page.evaluate(() => { window.addEventListener('livecomponent:batch-start', (e) => { window.onBatchStart(e.detail); }); window.addEventListener('livecomponent:batch-complete', (e) => { window.onBatchComplete(e.detail); }); }); // Trigger batch await page.click('[data-lc-action="increment"]'); await page.click('[data-lc-action="increment"]'); await page.click('[data-lc-action="increment"]'); await page.waitForResponse(response => response.url().includes('/livecomponent') && response.status() === 200 ); // Wait for events await page.waitForTimeout(100); // Verify batch-start event expect(batchStartEvent).not.toBeNull(); expect(batchStartEvent).toHaveProperty('actionCount'); expect(batchStartEvent.actionCount).toBe(3); // Verify batch-complete event expect(batchCompleteEvent).not.toBeNull(); expect(batchCompleteEvent).toHaveProperty('results'); expect(batchCompleteEvent.results.length).toBe(3); expect(batchCompleteEvent).toHaveProperty('duration'); }); test('should handle concurrent batches correctly', async ({ page }) => { const requests = []; page.on('request', request => { if (request.url().includes('/livecomponent')) { requests.push(request); } }); // Trigger first batch await page.click('[data-lc-action="increment"]'); await page.click('[data-lc-action="increment"]'); // Wait for partial debounce await page.waitForTimeout(30); // Trigger second batch (while first is still debouncing) await page.click('[data-lc-action="updateTitle"]'); await page.click('[data-lc-action="updateTitle"]'); // Wait for all batches to complete await page.waitForTimeout(200); // Should have sent batches separately expect(requests.length).toBeGreaterThanOrEqual(1); // Verify final state is consistent const counter = await page.locator('.counter-value').textContent(); expect(parseInt(counter)).toBe(2); // Both increments applied }); test('should handle batch timeout gracefully', async ({ page }) => { // Mock slow server response await page.route('**/livecomponent/**', async route => { await new Promise(resolve => setTimeout(resolve, 5000)); // 5 second delay route.continue(); }); // Trigger batch await page.click('[data-lc-action="increment"]'); // Should show loading state const loadingIndicator = page.locator('.livecomponent-loading'); await expect(loadingIndicator).toBeVisible(); // Wait for timeout (should be less than 5 seconds) await page.waitForTimeout(3000); // Should show timeout error const errorNotification = page.locator('.livecomponent-error'); await expect(errorNotification).toBeVisible(); await expect(errorNotification).toContainText('timeout'); }); test('should allow disabling batching per action', async ({ page }) => { const requests = []; page.on('request', request => { if (request.url().includes('/livecomponent')) { requests.push(request); } }); // Trigger batchable actions await page.click('[data-lc-action="increment"]'); await page.click('[data-lc-action="increment"]'); // Trigger non-batchable action (critical/immediate) await page.click('[data-lc-action="criticalAction"]'); await page.waitForTimeout(200); // Should have 2 requests: // 1. Immediate critical action // 2. Batched increments expect(requests.length).toBeGreaterThanOrEqual(2); // First request should be the critical action (immediate) const firstRequest = JSON.parse(requests[0].postData()); expect(firstRequest.action).toBe('criticalAction'); expect(firstRequest).not.toHaveProperty('batch'); // Second request should be the batched increments const secondRequest = JSON.parse(requests[1].postData()); expect(secondRequest).toHaveProperty('batch'); expect(secondRequest.batch.length).toBe(2); }); });