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