/** * 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 = `Component ${i}`; 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 = `Initial Value`; 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 = 'Static Component'; 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/); }); });