Add E2E test suite for FABRIC web dashboard covering all critical user flows: - Worker selection and detail view navigation - WebSocket connection and real-time event streaming - Command palette search and execution - Focus mode pin/unpin operations Also adds test:e2e npm scripts for running Playwright tests. Test files added: - e2e/critical-flows.spec.ts - Integrated critical flow tests - e2e/websocket-event-streaming.spec.ts - WebSocket event delivery - e2e/command-palette-workflows.spec.ts - Command palette workflows - e2e/focus-mode-multipin.spec.ts - Focus mode with multiple pins - e2e/websocket-reconnection.spec.ts - Reconnection scenarios - e2e/edge-cases.spec.ts - Edge cases and error handling - e2e/web-dashboard.spec.ts - Basic dashboard tests Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
567 lines
19 KiB
TypeScript
567 lines
19 KiB
TypeScript
/**
|
|
* E2E Tests: WebSocket Event Streaming
|
|
*
|
|
* Comprehensive tests for real-time WebSocket event streaming including:
|
|
* - Event delivery and ordering
|
|
* - Real-time updates to UI components
|
|
* - Multi-worker event streaming
|
|
* - Event filtering during streaming
|
|
* - Large event batch handling
|
|
* - Event throttling and debouncing
|
|
*/
|
|
|
|
import { test, expect, Page } from '@playwright/test';
|
|
|
|
const BASE_URL = 'http://localhost:3000';
|
|
|
|
/**
|
|
* Helper: Wait for WebSocket connection to be established
|
|
*/
|
|
async function waitForWebSocketConnection(page: Page, timeout = 10000): Promise<void> {
|
|
await page.waitForSelector('.connection-status .status-dot.connected', { timeout });
|
|
}
|
|
|
|
/**
|
|
* Helper: Get event count from activity stream
|
|
*/
|
|
async function getEventCount(page: Page): Promise<number> {
|
|
const events = page.locator('.activity-stream .event-item, .activity-stream .event, .log-entry');
|
|
return await events.count();
|
|
}
|
|
|
|
/**
|
|
* Helper: Get worker count
|
|
*/
|
|
async function getWorkerCount(page: Page): Promise<number> {
|
|
const workerCards = page.locator('.worker-card');
|
|
return await workerCards.count();
|
|
}
|
|
|
|
/**
|
|
* Helper: Get text content of last event
|
|
*/
|
|
async function getLastEventText(page: Page): Promise<string | null> {
|
|
const lastEvent = page.locator('.activity-stream .event-item, .activity-stream .event, .log-entry').last();
|
|
const count = await lastEvent.count();
|
|
if (count > 0) {
|
|
return await lastEvent.textContent();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
test.describe('E2E: WebSocket Event Streaming', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.goto(BASE_URL);
|
|
});
|
|
|
|
test.describe('Initial Connection and Handshake', () => {
|
|
test('should establish WebSocket connection on page load', async ({ page }) => {
|
|
// Connection status should be visible
|
|
const connectionStatus = page.locator('.connection-status');
|
|
await expect(connectionStatus).toBeVisible();
|
|
|
|
// Should transition to connected state
|
|
await waitForWebSocketConnection(page);
|
|
|
|
// Status dot should show connected
|
|
const statusDot = page.locator('.status-dot.connected');
|
|
await expect(statusDot).toBeVisible();
|
|
});
|
|
|
|
test('should receive initial data after connection', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
await page.waitForTimeout(1000);
|
|
|
|
// Should have received some data
|
|
const workerGrid = page.locator('.worker-grid');
|
|
await expect(workerGrid).toBeVisible();
|
|
|
|
const activityStream = page.locator('.activity-stream');
|
|
await expect(activityStream).toBeVisible();
|
|
});
|
|
|
|
test('should show connection state in UI', async ({ page }) => {
|
|
const connectionStatus = page.locator('.connection-status');
|
|
await expect(connectionStatus).toBeVisible();
|
|
|
|
// Wait for connection
|
|
await waitForWebSocketConnection(page);
|
|
|
|
// Should show connected text or indicator
|
|
const text = await connectionStatus.textContent();
|
|
const isConnected = text?.toLowerCase().includes('connected') ||
|
|
await connectionStatus.locator('.connected').count() > 0;
|
|
expect(isConnected).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
test.describe('Real-time Event Delivery', () => {
|
|
test('should deliver events in chronological order', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
await page.waitForTimeout(1000);
|
|
|
|
const events = page.locator('.activity-stream .event-item, .activity-stream .event');
|
|
const count = await events.count();
|
|
|
|
if (count > 1) {
|
|
// Get timestamps of first and last events
|
|
const firstEventTime = await events.first().locator('.event-time, .timestamp').textContent();
|
|
const lastEventTime = await events.last().locator('.event-time, .timestamp').textContent();
|
|
|
|
// Both should have timestamps
|
|
expect(firstEventTime).toBeTruthy();
|
|
expect(lastEventTime).toBeTruthy();
|
|
|
|
// If we can parse them, first should be earlier than last
|
|
// (events are ordered newest to oldest or oldest to newest)
|
|
expect(firstEventTime).not.toBe(lastEventTime);
|
|
}
|
|
});
|
|
|
|
test('should update event count in real-time', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
|
|
const initialCount = await getEventCount(page);
|
|
|
|
// Wait for new events to arrive
|
|
await page.waitForTimeout(3000);
|
|
|
|
const newCount = await getEventCount(page);
|
|
|
|
// Event count should update (may stay same if no new events)
|
|
expect(newCount).toBeGreaterThanOrEqual(initialCount);
|
|
});
|
|
|
|
test('should display worker information in events', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
await page.waitForTimeout(1000);
|
|
|
|
const events = page.locator('.activity-stream .event-item, .activity-stream .event');
|
|
const count = await events.count();
|
|
|
|
if (count > 0) {
|
|
// First event should have worker information
|
|
const firstEvent = events.first();
|
|
const workerInfo = firstEvent.locator('[class*="worker"], .worker-id, [data-worker]');
|
|
const hasWorker = await workerInfo.count() > 0;
|
|
|
|
if (hasWorker) {
|
|
await expect(workerInfo.first()).toBeVisible();
|
|
}
|
|
}
|
|
});
|
|
|
|
test('should show event levels with correct styling', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
await page.waitForTimeout(1000);
|
|
|
|
// Check for events with different levels
|
|
const infoEvents = page.locator('.activity-stream .event-item.info, .activity-stream .event.info, [class*="level-info"]');
|
|
const errorEvents = page.locator('.activity-stream .event-item.error, .activity-stream .event.error, [class*="level-error"]');
|
|
const warnEvents = page.locator('.activity-stream .event-item.warn, .activity-stream .event.warn, [class*="level-warn"]');
|
|
|
|
// At least one type of event might exist
|
|
const hasInfo = await infoEvents.count() > 0;
|
|
const hasError = await errorEvents.count() > 0;
|
|
const hasWarn = await warnEvents.count() > 0;
|
|
|
|
expect(hasInfo || hasError || hasWarn).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
test.describe('Multi-Worker Event Streaming', () => {
|
|
test('should show events from multiple workers', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
await page.waitForTimeout(2000);
|
|
|
|
const workerCards = page.locator('.worker-card');
|
|
const workerCount = await workerCards.count();
|
|
|
|
if (workerCount > 1) {
|
|
// Get worker IDs from cards
|
|
const workerIds: string[] = [];
|
|
for (let i = 0; i < Math.min(workerCount, 3); i++) {
|
|
const text = await workerCards.nth(i).textContent();
|
|
const match = text?.match(/w-[\w-]+/);
|
|
if (match) {
|
|
workerIds.push(match[0]);
|
|
}
|
|
}
|
|
|
|
if (workerIds.length > 1) {
|
|
// Activity stream should contain events from multiple workers
|
|
const events = page.locator('.activity-stream .event-item, .activity-stream .event');
|
|
const eventCount = await events.count();
|
|
|
|
if (eventCount > 0) {
|
|
// Check that events reference different workers
|
|
const workersInEvents = new Set<string>();
|
|
for (let i = 0; i < Math.min(eventCount, 10); i++) {
|
|
const text = await events.nth(i).textContent();
|
|
for (const workerId of workerIds) {
|
|
if (text?.includes(workerId)) {
|
|
workersInEvents.add(workerId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Should have at least one worker's events
|
|
expect(workersInEvents.size).toBeGreaterThanOrEqual(1);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('should update worker grid in real-time', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
|
|
const initialWorkerCount = await getWorkerCount(page);
|
|
|
|
// Wait for potential updates
|
|
await page.waitForTimeout(3000);
|
|
|
|
const newWorkerCount = await getWorkerCount(page);
|
|
|
|
// Worker grid should be present and may have updated
|
|
const workerGrid = page.locator('.worker-grid');
|
|
await expect(workerGrid).toBeVisible();
|
|
});
|
|
|
|
test('should show worker status changes', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
await page.waitForTimeout(1000);
|
|
|
|
const workerCards = page.locator('.worker-card');
|
|
const count = await workerCards.count();
|
|
|
|
if (count > 0) {
|
|
// Check for status indicators
|
|
const statusIndicators = page.locator('.worker-card .status, .worker-card [class*="status"]');
|
|
const hasStatus = await statusIndicators.count() > 0;
|
|
|
|
if (hasStatus) {
|
|
// Should have status on at least one worker
|
|
await expect(statusIndicators.first()).toBeVisible();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('Event Filtering During Streaming', () => {
|
|
test('should filter activity stream when worker is selected', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
await page.waitForTimeout(1000);
|
|
|
|
const workerCard = page.locator('.worker-card').first();
|
|
const count = await workerCard.count();
|
|
|
|
if (count > 0) {
|
|
// Get initial event count
|
|
const initialEventCount = await getEventCount(page);
|
|
|
|
// Click on worker to select
|
|
await workerCard.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Activity stream should now be filtered
|
|
const activityStream = page.locator('.activity-stream');
|
|
await expect(activityStream).toBeVisible();
|
|
|
|
// May show filtered indicator or different event count
|
|
const filteredIndicator = page.locator('.filtered-indicator, .filter-indicator, [class*="worker-filter"]');
|
|
const hasFilteredIndicator = await filteredIndicator.count() > 0;
|
|
|
|
if (hasFilteredIndicator) {
|
|
await expect(filteredIndicator.first()).toBeVisible();
|
|
}
|
|
}
|
|
});
|
|
|
|
test('should show all events when worker is deselected', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
await page.waitForTimeout(1000);
|
|
|
|
const workerCard = page.locator('.worker-card').first();
|
|
const count = await workerCard.count();
|
|
|
|
if (count > 0) {
|
|
// Select worker
|
|
await workerCard.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Deselect (click again or ESC)
|
|
await page.keyboard.press('Escape');
|
|
await page.waitForTimeout(500);
|
|
|
|
// Should show all events again
|
|
const activityStream = page.locator('.activity-stream');
|
|
await expect(activityStream).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test('should apply level filter during streaming', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
|
|
// Open command palette to set filter
|
|
await page.keyboard.press('Meta+k');
|
|
let paletteVisible = await page.locator('.cp-overlay').isVisible().catch(() => false);
|
|
if (!paletteVisible) {
|
|
await page.keyboard.press('Control+k');
|
|
}
|
|
|
|
await page.waitForSelector('.cp-overlay', { timeout: 5000 });
|
|
|
|
// Type filter command
|
|
const input = page.locator('.cp-input');
|
|
await input.fill('filter:level:error');
|
|
await page.waitForTimeout(200);
|
|
await page.keyboard.press('Enter');
|
|
await page.waitForTimeout(500);
|
|
|
|
// Activity stream should be filtered
|
|
const activityStream = page.locator('.activity-stream');
|
|
await expect(activityStream).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe('Large Event Batch Handling', () => {
|
|
test('should handle large number of events gracefully', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
await page.waitForTimeout(2000);
|
|
|
|
const eventCount = await getEventCount(page);
|
|
|
|
// Activity stream should handle any number of events
|
|
const activityStream = page.locator('.activity-stream');
|
|
await expect(activityStream).toBeVisible();
|
|
|
|
// Scroll should work
|
|
await page.evaluate(() => {
|
|
const stream = document.querySelector('.activity-stream');
|
|
if (stream) {
|
|
stream.scrollTop = stream.scrollHeight;
|
|
}
|
|
});
|
|
|
|
await page.waitForTimeout(100);
|
|
});
|
|
|
|
test('should maintain performance with many workers', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
await page.waitForTimeout(2000);
|
|
|
|
const workerCount = await getWorkerCount(page);
|
|
|
|
// UI should remain responsive
|
|
const workerGrid = page.locator('.worker-grid');
|
|
await expect(workerGrid).toBeVisible();
|
|
|
|
// Click should work
|
|
await workerGrid.click();
|
|
await page.waitForTimeout(100);
|
|
});
|
|
|
|
test('should virtualize or paginate large event lists', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
await page.waitForTimeout(1000);
|
|
|
|
const activityStream = page.locator('.activity-stream');
|
|
await expect(activityStream).toBeVisible();
|
|
|
|
// Should have scrollable area
|
|
const scrollHeight = await activityStream.evaluate(el => el.scrollHeight);
|
|
const clientHeight = await activityStream.evaluate(el => el.clientHeight);
|
|
|
|
// If there are many events, scrollHeight > clientHeight
|
|
if (scrollHeight > clientHeight) {
|
|
// Scrolling should work
|
|
await activityStream.evaluate((el) => {
|
|
el.scrollTop = el.scrollHeight / 2;
|
|
});
|
|
|
|
await page.waitForTimeout(100);
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('Real-time UI Updates', () => {
|
|
test('should update timeline when new events arrive', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
await page.waitForTimeout(1000);
|
|
|
|
const timeline = page.locator('.timeline-view, .timeline-panel');
|
|
const hasTimeline = await timeline.count() > 0;
|
|
|
|
if (hasTimeline) {
|
|
await expect(timeline).toBeVisible();
|
|
|
|
// Wait for potential updates
|
|
await page.waitForTimeout(2000);
|
|
|
|
// Timeline should still be visible
|
|
await expect(timeline).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test('should update worker cards in real-time', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
|
|
const workerGrid = page.locator('.worker-grid');
|
|
await expect(workerGrid).toBeVisible();
|
|
|
|
// Wait for updates
|
|
await page.waitForTimeout(3000);
|
|
|
|
// Grid should still be functional
|
|
await expect(workerGrid).toBeVisible();
|
|
});
|
|
|
|
test('should show new event indicator', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
|
|
// Wait for events to arrive
|
|
await page.waitForTimeout(2000);
|
|
|
|
const activityStream = page.locator('.activity-stream');
|
|
await expect(activityStream).toBeVisible();
|
|
|
|
// Check for new activity indicators
|
|
const newActivity = page.locator('.new-activity, [class*="new-event"]');
|
|
const hasNewActivity = await newActivity.count() > 0;
|
|
|
|
// May or may not have new activity depending on timing
|
|
if (hasNewActivity) {
|
|
await expect(newActivity.first()).toBeVisible();
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('WebSocket Message Handling', () => {
|
|
test('should handle JSON parse errors gracefully', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
|
|
// App should remain functional even with bad data
|
|
await page.waitForTimeout(1000);
|
|
|
|
const body = page.locator('body');
|
|
await expect(body).toBeVisible();
|
|
});
|
|
|
|
test('should handle malformed event data', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
|
|
// Should handle incomplete event data
|
|
await page.waitForTimeout(1000);
|
|
|
|
const activityStream = page.locator('.activity-stream');
|
|
await expect(activityStream).toBeVisible();
|
|
});
|
|
|
|
test('should handle missing optional fields', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
await page.waitForTimeout(1000);
|
|
|
|
const events = page.locator('.activity-stream .event-item, .activity-stream .event');
|
|
const count = await events.count();
|
|
|
|
if (count > 0) {
|
|
// Events should render even with optional fields missing
|
|
await expect(events.first()).toBeVisible();
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('Reconnection with Event Recovery', () => {
|
|
test('should request events after reconnection', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
|
|
const initialEventCount = await getEventCount(page);
|
|
|
|
// Simulate reconnection by going offline and online
|
|
await page.context().setOffline(true);
|
|
await page.waitForTimeout(1000);
|
|
await page.context().setOffline(false);
|
|
|
|
// Should reconnect
|
|
await page.waitForTimeout(2000);
|
|
|
|
const connectionStatus = page.locator('.connection-status');
|
|
await expect(connectionStatus).toBeVisible();
|
|
|
|
// Events should be present
|
|
const newEventCount = await getEventCount(page);
|
|
expect(newEventCount).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
test('should preserve UI state during reconnection', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
await page.waitForTimeout(1000);
|
|
|
|
// Select a worker
|
|
const workerCard = page.locator('.worker-card').first();
|
|
const count = await workerCard.count();
|
|
|
|
if (count > 0) {
|
|
await workerCard.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Simulate reconnection
|
|
await page.context().setOffline(true);
|
|
await page.waitForTimeout(500);
|
|
await page.context().setOffline(false);
|
|
|
|
await page.waitForTimeout(1000);
|
|
|
|
// Worker should still be selected
|
|
const isSelected = await workerCard.evaluate(el => el.classList.contains('selected'));
|
|
expect(isSelected).toBeTruthy();
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('Event Streaming Performance', () => {
|
|
test('should not block UI during event processing', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
|
|
// UI should be interactive
|
|
const body = page.locator('body');
|
|
await body.click();
|
|
await page.waitForTimeout(100);
|
|
|
|
// Should still be responsive
|
|
await expect(body).toBeVisible();
|
|
});
|
|
|
|
test('should throttle rapid updates', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
|
|
// Wait for multiple potential update cycles
|
|
for (let i = 0; i < 10; i++) {
|
|
await page.waitForTimeout(100);
|
|
const activityStream = page.locator('.activity-stream');
|
|
await expect(activityStream).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test('should debounce filter changes', async ({ page }) => {
|
|
await waitForWebSocketConnection(page);
|
|
|
|
const workerCard = page.locator('.worker-card').first();
|
|
const count = await workerCard.count();
|
|
|
|
if (count > 0) {
|
|
// Rapidly select and deselect
|
|
for (let i = 0; i < 3; i++) {
|
|
await workerCard.click();
|
|
await page.waitForTimeout(50);
|
|
await page.keyboard.press('Escape');
|
|
await page.waitForTimeout(50);
|
|
}
|
|
|
|
// Should remain stable
|
|
const activityStream = page.locator('.activity-stream');
|
|
await expect(activityStream).toBeVisible();
|
|
}
|
|
});
|
|
});
|
|
});
|