- 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.
477 lines
17 KiB
JavaScript
477 lines
17 KiB
JavaScript
/**
|
|
* LiveComponents E2E Tests - SSE Real-time Updates
|
|
*
|
|
* Tests for Server-Sent Events (SSE) real-time component updates:
|
|
* - SSE connection establishment
|
|
* - Real-time component updates
|
|
* - Connection state management
|
|
* - Automatic reconnection
|
|
* - Event stream parsing
|
|
* - Multiple concurrent SSE connections
|
|
*
|
|
* @see src/Framework/LiveComponents/Services/SseUpdateService.php
|
|
* @see resources/js/modules/LiveComponent.js (SSE handling)
|
|
*/
|
|
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
test.describe('LiveComponents - SSE Real-time Updates', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
// Navigate to SSE test page
|
|
await page.goto('/livecomponents/sse-test');
|
|
|
|
// Wait for LiveComponent to be initialized
|
|
await page.waitForFunction(() => window.LiveComponent !== undefined);
|
|
});
|
|
|
|
test('should establish SSE connection on component mount', async ({ page }) => {
|
|
// Wait for SSE connection to be established
|
|
await page.waitForFunction(() => {
|
|
const component = document.querySelector('[data-component-id]');
|
|
return component && component.dataset.sseConnected === 'true';
|
|
}, { timeout: 5000 });
|
|
|
|
// Verify connection indicator
|
|
const connectionStatus = page.locator('.sse-connection-status');
|
|
await expect(connectionStatus).toHaveClass(/connected|active/);
|
|
await expect(connectionStatus).toContainText(/connected|online/i);
|
|
});
|
|
|
|
test('should receive and apply real-time updates', async ({ page }) => {
|
|
// Get initial value
|
|
const initialValue = await page.locator('[data-sse-value]').textContent();
|
|
|
|
// Trigger server-side update via API
|
|
await page.evaluate(async () => {
|
|
await fetch('/api/trigger-sse-update', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ componentId: 'test-component', newValue: 'Updated via SSE' })
|
|
});
|
|
});
|
|
|
|
// Wait for SSE update to be applied
|
|
await page.waitForFunction(() => {
|
|
const element = document.querySelector('[data-sse-value]');
|
|
return element && element.textContent !== 'Updated via SSE';
|
|
}, { timeout: 5000 });
|
|
|
|
const updatedValue = await page.locator('[data-sse-value]').textContent();
|
|
expect(updatedValue).not.toBe(initialValue);
|
|
expect(updatedValue).toBe('Updated via SSE');
|
|
});
|
|
|
|
test('should parse different SSE event types correctly', async ({ page }) => {
|
|
const receivedEvents = [];
|
|
|
|
// Monitor SSE events
|
|
await page.exposeFunction('onSseEvent', (event) => {
|
|
receivedEvents.push(event);
|
|
});
|
|
|
|
await page.evaluate(() => {
|
|
window.addEventListener('livecomponent:sse-message', (e) => {
|
|
window.onSseEvent(e.detail);
|
|
});
|
|
});
|
|
|
|
// Trigger various event types
|
|
await page.evaluate(async () => {
|
|
await fetch('/api/trigger-sse-events', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
events: [
|
|
{ type: 'component-update', data: { field: 'title', value: 'New Title' } },
|
|
{ type: 'notification', data: { message: 'New message' } },
|
|
{ type: 'state-sync', data: { state: { counter: 42 } } }
|
|
]
|
|
})
|
|
});
|
|
});
|
|
|
|
// Wait for events
|
|
await page.waitForTimeout(1000);
|
|
|
|
// Should have received 3 events
|
|
expect(receivedEvents.length).toBeGreaterThanOrEqual(3);
|
|
|
|
// Verify event types
|
|
const eventTypes = receivedEvents.map(e => e.type);
|
|
expect(eventTypes).toContain('component-update');
|
|
expect(eventTypes).toContain('notification');
|
|
expect(eventTypes).toContain('state-sync');
|
|
});
|
|
|
|
test('should show connection lost indicator on disconnect', async ({ page }) => {
|
|
// Close SSE connection programmatically
|
|
await page.evaluate(() => {
|
|
const component = document.querySelector('[data-component-id]');
|
|
const componentId = component.dataset.componentId;
|
|
window.LiveComponent.closeSseConnection(componentId);
|
|
});
|
|
|
|
// Connection status should show disconnected
|
|
const connectionStatus = page.locator('.sse-connection-status');
|
|
await expect(connectionStatus).toHaveClass(/disconnected|offline/);
|
|
await expect(connectionStatus).toContainText(/disconnected|offline/i);
|
|
});
|
|
|
|
test('should automatically reconnect after connection loss', async ({ page }) => {
|
|
// Simulate connection loss
|
|
await page.evaluate(() => {
|
|
const component = document.querySelector('[data-component-id]');
|
|
const componentId = component.dataset.componentId;
|
|
window.LiveComponent.closeSseConnection(componentId);
|
|
});
|
|
|
|
// Wait for disconnect
|
|
await page.waitForSelector('.sse-connection-status.disconnected');
|
|
|
|
// Should automatically attempt reconnection
|
|
await page.waitForFunction(() => {
|
|
const component = document.querySelector('[data-component-id]');
|
|
return component && component.dataset.sseConnected === 'true';
|
|
}, { timeout: 10000 });
|
|
|
|
// Should be reconnected
|
|
const connectionStatus = page.locator('.sse-connection-status');
|
|
await expect(connectionStatus).toHaveClass(/connected|active/);
|
|
});
|
|
|
|
test('should handle SSE reconnection with exponential backoff', async ({ page }) => {
|
|
const reconnectAttempts = [];
|
|
|
|
// Monitor reconnection attempts
|
|
await page.exposeFunction('onReconnectAttempt', (attempt) => {
|
|
reconnectAttempts.push(attempt);
|
|
});
|
|
|
|
await page.evaluate(() => {
|
|
window.addEventListener('livecomponent:sse-reconnect-attempt', (e) => {
|
|
window.onReconnectAttempt({
|
|
timestamp: Date.now(),
|
|
attemptNumber: e.detail.attemptNumber,
|
|
delay: e.detail.delay
|
|
});
|
|
});
|
|
});
|
|
|
|
// Simulate connection loss
|
|
await page.evaluate(() => {
|
|
const component = document.querySelector('[data-component-id]');
|
|
const componentId = component.dataset.componentId;
|
|
window.LiveComponent.closeSseConnection(componentId);
|
|
});
|
|
|
|
// Wait for multiple reconnection attempts
|
|
await page.waitForTimeout(5000);
|
|
|
|
// Should have multiple attempts
|
|
expect(reconnectAttempts.length).toBeGreaterThan(1);
|
|
|
|
// Delays should increase (exponential backoff)
|
|
if (reconnectAttempts.length >= 2) {
|
|
expect(reconnectAttempts[1].delay).toBeGreaterThan(reconnectAttempts[0].delay);
|
|
}
|
|
});
|
|
|
|
test('should handle multiple SSE connections for different components', async ({ page }) => {
|
|
// Create multiple components
|
|
await page.evaluate(() => {
|
|
const container = document.querySelector('#component-container');
|
|
|
|
for (let i = 1; i <= 3; i++) {
|
|
const component = document.createElement('div');
|
|
component.dataset.componentId = `test-component-${i}`;
|
|
component.dataset.componentName = 'TestComponent';
|
|
component.dataset.sseEnabled = 'true';
|
|
component.innerHTML = `<span data-sse-value>Component ${i}</span>`;
|
|
container.appendChild(component);
|
|
}
|
|
});
|
|
|
|
// Initialize SSE for all components
|
|
await page.evaluate(() => {
|
|
window.LiveComponent.initializeAllComponents();
|
|
});
|
|
|
|
// Wait for all connections
|
|
await page.waitForFunction(() => {
|
|
const components = document.querySelectorAll('[data-sse-enabled="true"]');
|
|
return Array.from(components).every(c => c.dataset.sseConnected === 'true');
|
|
}, { timeout: 10000 });
|
|
|
|
// All components should have active SSE connections
|
|
const connectedComponents = page.locator('[data-sse-connected="true"]');
|
|
await expect(connectedComponents).toHaveCount(3);
|
|
});
|
|
|
|
test('should update only target component on SSE message', async ({ page }) => {
|
|
// Create two components
|
|
await page.evaluate(() => {
|
|
const container = document.querySelector('#component-container');
|
|
|
|
['component-1', 'component-2'].forEach(id => {
|
|
const component = document.createElement('div');
|
|
component.dataset.componentId = id;
|
|
component.dataset.componentName = 'TestComponent';
|
|
component.dataset.sseEnabled = 'true';
|
|
component.innerHTML = `<span data-value>Initial Value</span>`;
|
|
container.appendChild(component);
|
|
});
|
|
|
|
window.LiveComponent.initializeAllComponents();
|
|
});
|
|
|
|
// Wait for connections
|
|
await page.waitForTimeout(1000);
|
|
|
|
// Trigger update for component-1 only
|
|
await page.evaluate(async () => {
|
|
await fetch('/api/trigger-sse-update', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
componentId: 'component-1',
|
|
newValue: 'Updated Value'
|
|
})
|
|
});
|
|
});
|
|
|
|
await page.waitForTimeout(1000);
|
|
|
|
// Component 1 should be updated
|
|
const component1Value = await page.locator('#component-1 [data-value]').textContent();
|
|
expect(component1Value).toBe('Updated Value');
|
|
|
|
// Component 2 should remain unchanged
|
|
const component2Value = await page.locator('#component-2 [data-value]').textContent();
|
|
expect(component2Value).toBe('Initial Value');
|
|
});
|
|
|
|
test('should handle SSE heartbeat/keep-alive messages', async ({ page }) => {
|
|
const heartbeats = [];
|
|
|
|
// Monitor heartbeat messages
|
|
await page.exposeFunction('onHeartbeat', (timestamp) => {
|
|
heartbeats.push(timestamp);
|
|
});
|
|
|
|
await page.evaluate(() => {
|
|
window.addEventListener('livecomponent:sse-heartbeat', () => {
|
|
window.onHeartbeat(Date.now());
|
|
});
|
|
});
|
|
|
|
// Wait for multiple heartbeats (typically every 15-30 seconds)
|
|
await page.waitForTimeout(35000);
|
|
|
|
// Should have received at least 1 heartbeat
|
|
expect(heartbeats.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
test('should emit SSE lifecycle events', async ({ page }) => {
|
|
const events = {
|
|
opened: null,
|
|
message: [],
|
|
error: null,
|
|
closed: null
|
|
};
|
|
|
|
await page.exposeFunction('onSseOpen', (event) => {
|
|
events.opened = event;
|
|
});
|
|
|
|
await page.exposeFunction('onSseMessage', (event) => {
|
|
events.message.push(event);
|
|
});
|
|
|
|
await page.exposeFunction('onSseError', (event) => {
|
|
events.error = event;
|
|
});
|
|
|
|
await page.exposeFunction('onSseClose', (event) => {
|
|
events.closed = event;
|
|
});
|
|
|
|
await page.evaluate(() => {
|
|
window.addEventListener('livecomponent:sse-open', (e) => {
|
|
window.onSseOpen(e.detail);
|
|
});
|
|
|
|
window.addEventListener('livecomponent:sse-message', (e) => {
|
|
window.onSseMessage(e.detail);
|
|
});
|
|
|
|
window.addEventListener('livecomponent:sse-error', (e) => {
|
|
window.onSseError(e.detail);
|
|
});
|
|
|
|
window.addEventListener('livecomponent:sse-close', (e) => {
|
|
window.onSseClose(e.detail);
|
|
});
|
|
});
|
|
|
|
// Wait for connection and messages
|
|
await page.waitForTimeout(2000);
|
|
|
|
// Verify open event
|
|
expect(events.opened).not.toBeNull();
|
|
expect(events.opened).toHaveProperty('componentId');
|
|
|
|
// Simulate close
|
|
await page.evaluate(() => {
|
|
const component = document.querySelector('[data-component-id]');
|
|
window.LiveComponent.closeSseConnection(component.dataset.componentId);
|
|
});
|
|
|
|
await page.waitForTimeout(500);
|
|
|
|
// Verify close event
|
|
expect(events.closed).not.toBeNull();
|
|
});
|
|
|
|
test('should handle SSE errors gracefully', async ({ page }) => {
|
|
// Mock SSE endpoint to return error
|
|
await page.route('**/livecomponent/sse/**', async (route) => {
|
|
route.fulfill({
|
|
status: 500,
|
|
body: 'Internal Server Error'
|
|
});
|
|
});
|
|
|
|
// Try to establish connection
|
|
await page.evaluate(() => {
|
|
const component = document.querySelector('[data-component-id]');
|
|
window.LiveComponent.initializeSse(component.dataset.componentId);
|
|
});
|
|
|
|
// Should show error state
|
|
const connectionStatus = page.locator('.sse-connection-status');
|
|
await expect(connectionStatus).toHaveClass(/error|failed/);
|
|
|
|
// Should display error notification
|
|
const errorNotification = page.locator('.sse-error-notification');
|
|
await expect(errorNotification).toBeVisible();
|
|
});
|
|
|
|
test('should batch SSE updates to prevent UI thrashing', async ({ page }) => {
|
|
const renderCount = [];
|
|
|
|
// Monitor component renders
|
|
await page.exposeFunction('onComponentRender', (timestamp) => {
|
|
renderCount.push(timestamp);
|
|
});
|
|
|
|
await page.evaluate(() => {
|
|
const originalRender = window.LiveComponent.renderComponent;
|
|
window.LiveComponent.renderComponent = function(...args) {
|
|
window.onComponentRender(Date.now());
|
|
return originalRender.apply(this, args);
|
|
};
|
|
});
|
|
|
|
// Send rapid SSE updates
|
|
await page.evaluate(async () => {
|
|
for (let i = 0; i < 10; i++) {
|
|
await fetch('/api/trigger-sse-update', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ value: `Update ${i}` })
|
|
});
|
|
}
|
|
});
|
|
|
|
await page.waitForTimeout(2000);
|
|
|
|
// Should have batched renders (less than 10)
|
|
expect(renderCount.length).toBeLessThan(10);
|
|
});
|
|
|
|
test('should allow disabling SSE per component', async ({ page }) => {
|
|
// Create component without SSE
|
|
await page.evaluate(() => {
|
|
const container = document.querySelector('#component-container');
|
|
const component = document.createElement('div');
|
|
component.dataset.componentId = 'no-sse-component';
|
|
component.dataset.componentName = 'TestComponent';
|
|
// No data-sse-enabled attribute
|
|
component.innerHTML = '<span>Static Component</span>';
|
|
container.appendChild(component);
|
|
|
|
window.LiveComponent.initializeAllComponents();
|
|
});
|
|
|
|
await page.waitForTimeout(1000);
|
|
|
|
// Component should NOT have SSE connection
|
|
const component = page.locator('[data-component-id="no-sse-component"]');
|
|
await expect(component).not.toHaveAttribute('data-sse-connected');
|
|
|
|
// Connection status should not exist
|
|
const connectionStatus = component.locator('.sse-connection-status');
|
|
await expect(connectionStatus).not.toBeVisible();
|
|
});
|
|
|
|
test('should close SSE connection on component unmount', async ({ page }) => {
|
|
let closeEventReceived = false;
|
|
|
|
await page.exposeFunction('onSseClose', () => {
|
|
closeEventReceived = true;
|
|
});
|
|
|
|
await page.evaluate(() => {
|
|
window.addEventListener('livecomponent:sse-close', () => {
|
|
window.onSseClose();
|
|
});
|
|
});
|
|
|
|
// Remove component from DOM
|
|
await page.evaluate(() => {
|
|
const component = document.querySelector('[data-component-id]');
|
|
component.remove();
|
|
});
|
|
|
|
await page.waitForTimeout(500);
|
|
|
|
// Should have closed SSE connection
|
|
expect(closeEventReceived).toBe(true);
|
|
});
|
|
|
|
test('should handle SSE authentication/authorization', async ({ page }) => {
|
|
// Mock SSE endpoint requiring authentication
|
|
await page.route('**/livecomponent/sse/**', async (route) => {
|
|
const headers = route.request().headers();
|
|
|
|
if (!headers['authorization']) {
|
|
route.fulfill({
|
|
status: 401,
|
|
body: 'Unauthorized'
|
|
});
|
|
} else {
|
|
route.continue();
|
|
}
|
|
});
|
|
|
|
// Set auth token
|
|
await page.evaluate(() => {
|
|
window.LiveComponent.setSseAuthToken('Bearer test-token-123');
|
|
});
|
|
|
|
// Initialize SSE
|
|
await page.evaluate(() => {
|
|
const component = document.querySelector('[data-component-id]');
|
|
window.LiveComponent.initializeSse(component.dataset.componentId);
|
|
});
|
|
|
|
// Should successfully connect with auth
|
|
await page.waitForFunction(() => {
|
|
const component = document.querySelector('[data-component-id]');
|
|
return component && component.dataset.sseConnected === 'true';
|
|
}, { timeout: 5000 });
|
|
|
|
const connectionStatus = page.locator('.sse-connection-status');
|
|
await expect(connectionStatus).toHaveClass(/connected|active/);
|
|
});
|
|
});
|