/**
* 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/);
});
});