diff --git a/e2e/command-palette-workflows.spec.ts b/e2e/command-palette-workflows.spec.ts new file mode 100644 index 0000000..8132c39 --- /dev/null +++ b/e2e/command-palette-workflows.spec.ts @@ -0,0 +1,589 @@ +/** + * E2E Tests: Command Palette Complex Workflows + * + * Tests for complex command palette workflows including: + * - Multi-command sequences + * - Chained operations + * - Context-aware suggestions + * - Recent command tracking + * - Integration with other features + */ + +import { test, expect, Page } from '@playwright/test'; + +const BASE_URL = 'http://localhost:3000'; + +/** + * Helper: Open command palette using keyboard shortcut + */ +async function openCommandPalette(page: Page): Promise { + 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'); + await page.waitForTimeout(100); +} + +/** + * Helper: Type query in command palette + */ +async function typeQuery(page: Page, query: string): Promise { + const input = page.locator('.cp-input'); + await input.clear(); + await input.fill(query); + await page.waitForTimeout(150); +} + +/** + * Helper: Execute command and wait for palette to close + */ +async function executeCommand(page: Page, query: string): Promise { + await openCommandPalette(page); + await typeQuery(page, query); + await page.waitForTimeout(150); + await page.keyboard.press('Enter'); + + // Wait for palette to close + const palette = page.locator('.cp-overlay'); + await palette.waitFor({ state: 'hidden', timeout: 3000 }).catch(() => {}); +} + +test.describe('E2E: Command Palette Complex Workflows', () => { + test.beforeEach(async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + }); + + test.describe('Multi-Command Sequences', () => { + test('should execute multiple commands in sequence', async ({ page }) => { + // Execute theme toggle + await executeCommand(page, 'theme:toggle'); + await page.waitForTimeout(200); + + // Execute focus mode toggle + await executeCommand(page, 'focus:toggle'); + await page.waitForTimeout(200); + + // Execute heatmap toggle + await executeCommand(page, 'show:heatmap'); + await page.waitForTimeout(200); + + // All commands should have executed + const heatmap = page.locator('.file-heatmap-panel, .heatmap-panel'); + const hasHeatmap = await heatmap.count() > 0; + + if (hasHeatmap) { + await expect(heatmap).toBeVisible(); + } + }); + + test('should handle rapid command execution', async ({ page }) => { + const commands = [ + 'theme:dark', + 'show:timeline', + 'show:analytics', + 'theme:light', + ]; + + for (const cmd of commands) { + await openCommandPalette(page); + await typeQuery(page, cmd); + await page.waitForTimeout(100); + await page.keyboard.press('Enter'); + await page.waitForTimeout(100); + } + + // Should remain stable + const body = page.locator('body'); + await expect(body).toBeVisible(); + }); + + test('should track recently used commands across executions', async ({ page }) => { + // Execute several commands + await executeCommand(page, 'theme:toggle'); + await page.waitForTimeout(200); + + await executeCommand(page, 'focus:toggle'); + await page.waitForTimeout(200); + + await executeCommand(page, 'show:heatmap'); + await page.waitForTimeout(200); + + // Open palette - should show recent commands + await openCommandPalette(page); + + const commands = page.locator('.cp-item'); + const count = await commands.count(); + + expect(count).toBeGreaterThan(0); + }); + }); + + test.describe('Chained Operations', () => { + test('should support worker selection followed by detail view', async ({ page }) => { + await page.waitForTimeout(1000); + + const workerCard = page.locator('.worker-card').first(); + const count = await workerCard.count(); + + if (count > 0) { + // Get worker ID from card + const workerText = await workerCard.textContent(); + const workerMatch = workerText?.match(/w-[\w-]+/); + + if (workerMatch) { + const workerId = workerMatch[0]; + + // Use command palette to select worker + await executeCommand(page, `worker:${workerId}`); + await page.waitForTimeout(200); + + // Worker should be selected + await expect(workerCard).toHaveClass(/selected/); + + // Detail panel should be visible + const detailPanel = page.locator('.worker-detail-panel, .worker-detail'); + const hasDetail = await detailPanel.count() > 0; + + if (hasDetail) { + await expect(detailPanel).toBeVisible(); + } + } + } + }); + + test('should support focus mode setup with pinning', async ({ page }) => { + await page.waitForTimeout(1000); + + const workerCard = page.locator('.worker-card').first(); + const count = await workerCard.count(); + + if (count > 0) { + // Get worker ID + const workerText = await workerCard.textContent(); + const workerMatch = workerText?.match(/w-[\w-]+/); + + if (workerMatch) { + const workerId = workerMatch[0]; + + // Enable focus mode + await executeCommand(page, 'focus:toggle'); + await page.waitForTimeout(200); + + // Pin worker + await workerCard.click(); + await page.waitForTimeout(100); + + // Focus mode should be active + const focusToggle = page.locator('.focus-mode-toggle'); + await expect(focusToggle).toHaveClass(/active/); + + // Worker should be pinned + await expect(workerCard).toHaveClass(/pinned/); + } + } + }); + + test('should support filter then view change sequence', async ({ page }) => { + // Set level filter + await executeCommand(page, 'filter:level:error'); + await page.waitForTimeout(200); + + // Change view to heatmap + await executeCommand(page, 'show:heatmap'); + await page.waitForTimeout(200); + + // Both operations should complete + const heatmap = page.locator('.file-heatmap-panel, .heatmap-panel'); + const hasHeatmap = await heatmap.count() > 0; + + if (hasHeatmap) { + await expect(heatmap).toBeVisible(); + } + }); + }); + + test.describe('Context-Aware Suggestions', () => { + test('should show worker-specific commands when worker exists', async ({ page }) => { + await page.waitForTimeout(1000); + + await openCommandPalette(page); + + // Type "worker" to see worker commands + await typeQuery(page, 'worker'); + + const workerCategory = page.locator('.cp-category-header').filter({ hasText: 'Workers' }); + const hasWorkers = await workerCategory.count() > 0; + + if (hasWorkers) { + await expect(workerCategory).toBeVisible(); + + // Should have worker items + const workerItems = page.locator('.cp-item').filter({ hasText: /w-/ }); + const workerCount = await workerItems.count(); + expect(workerCount).toBeGreaterThan(0); + } + }); + + test('should show view commands based on current state', async ({ page }) => { + await openCommandPalette(page); + + // Type "show" to see view commands + await typeQuery(page, 'show'); + + const commands = page.locator('.cp-item'); + const count = await commands.count(); + + expect(count).toBeGreaterThan(0); + + // Should have common view commands + let hasShowCommand = false; + for (let i = 0; i < count; i++) { + const text = await commands.nth(i).textContent(); + if (text?.toLowerCase().includes('show:')) { + hasShowCommand = true; + break; + } + } + + expect(hasShowCommand).toBeTruthy(); + }); + + test('should suggest filter commands based on available options', async ({ page }) => { + await openCommandPalette(page); + + // Type "filter" to see filter commands + await typeQuery(page, 'filter'); + + const commands = page.locator('.cp-item'); + const count = await commands.count(); + + expect(count).toBeGreaterThan(0); + + // Should have level filter + let hasLevelFilter = false; + for (let i = 0; i < count; i++) { + const text = await commands.nth(i).textContent(); + if (text?.toLowerCase().includes('level')) { + hasLevelFilter = true; + break; + } + } + + expect(hasLevelFilter).toBeTruthy(); + }); + }); + + test.describe('Integration with Features', () => { + test('should integrate command palette with timeline view', async ({ page }) => { + // Toggle timeline via command palette + await executeCommand(page, 'show:timeline'); + await page.waitForTimeout(200); + + // Timeline should be visible + const timeline = page.locator('.timeline-view, .timeline-panel'); + await expect(timeline).toBeVisible(); + }); + + test('should integrate command palette with analytics', async ({ page }) => { + // Show analytics via command palette + await executeCommand(page, 'show:analytics'); + await page.waitForTimeout(200); + + // Analytics panel should be visible + const analytics = page.locator('.analytics-dashboard, .analytics-panel'); + const hasAnalytics = await analytics.count() > 0; + + if (hasAnalytics) { + await expect(analytics).toBeVisible(); + } + }); + + test('should integrate command palette with replay', async ({ page }) => { + // Show replay via command palette + await executeCommand(page, 'show:replay'); + await page.waitForTimeout(200); + + // Replay panel should be visible + const replay = page.locator('.session-replay-panel'); + await expect(replay).toBeVisible(); + }); + + test('should integrate command palette with export', async ({ page }) => { + // Export current session via command palette + await executeCommand(page, 'export:link'); + await page.waitForTimeout(200); + + // Replay panel should open with export option + const replay = page.locator('.session-replay-panel'); + await expect(replay).toBeVisible(); + }); + }); + + test.describe('Recent Commands Tracking', () => { + test('should prioritize recently used commands', async ({ page }) => { + // Execute a command + await executeCommand(page, 'theme:toggle'); + await page.waitForTimeout(200); + + // Open palette and type partial match + await openCommandPalette(page); + await typeQuery(page, 'theme'); + + // Theme command should be prominent + const commands = page.locator('.cp-item'); + const count = await commands.count(); + + expect(count).toBeGreaterThan(0); + + // First result should relate to theme + const firstText = await commands.first().textContent(); + expect(firstText?.toLowerCase()).toContain('theme'); + }); + + test('should limit recent commands to reasonable number', async ({ page }) => { + // Execute multiple commands + const commands = [ + 'theme:toggle', + 'show:heatmap', + 'show:timeline', + 'focus:toggle', + 'show:analytics', + ]; + + for (const cmd of commands) { + await executeCommand(page, cmd); + await page.waitForTimeout(200); + } + + // Open palette + await openCommandPalette(page); + + // Total commands should be reasonable (not excessive) + const allCommands = page.locator('.cp-item'); + const count = await allCommands.count(); + + expect(count).toBeLessThan(100); // Reasonable limit + }); + + test('should persist recent commands across sessions', async ({ page }) => { + // Execute a command + await executeCommand(page, 'theme:dark'); + await page.waitForTimeout(200); + + // Reload page + await page.reload(); + await page.waitForLoadState('networkidle'); + + // Open palette - recent command should be tracked + await openCommandPalette(page); + + // Should have commands available + const commands = page.locator('.cp-item'); + const count = await commands.count(); + + expect(count).toBeGreaterThan(0); + }); + }); + + test.describe('Error Handling in Workflows', () => { + test('should handle invalid command gracefully', async ({ page }) => { + await openCommandPalette(page); + + // Type invalid command + await typeQuery(page, 'invalid:command:that:does:not:exist'); + + // Should show empty state + const emptyState = page.locator('.cp-empty'); + await expect(emptyState).toBeVisible(); + + // Pressing Enter should close palette safely + await page.keyboard.press('Enter'); + + const palette = page.locator('.cp-overlay'); + await expect(palette).not.toBeVisible(); + }); + + test('should handle command execution failure gracefully', async ({ page }) => { + // Try to execute a command that might fail + await executeCommand(page, 'show:nonexistent'); + + // App should remain functional + const body = page.locator('body'); + await expect(body).toBeVisible(); + + // Command palette should close + const palette = page.locator('.cp-overlay'); + await expect(palette).not.toBeVisible(); + }); + + test('should recover from errors during command execution', async ({ page }) => { + // Execute multiple commands, some might fail + const commands = [ + 'theme:toggle', + 'show:nonexistent', + 'focus:toggle', + ]; + + for (const cmd of commands) { + await openCommandPalette(page); + await typeQuery(page, cmd); + await page.waitForTimeout(100); + await page.keyboard.press('Enter'); + await page.waitForTimeout(200); + } + + // App should still be functional + const body = page.locator('body'); + await expect(body).toBeVisible(); + }); + }); + + test.describe('Advanced Workflows', () => { + test('should support workflow: select worker → filter → export', async ({ page }) => { + await page.waitForTimeout(1000); + + const workerCard = page.locator('.worker-card').first(); + const count = await workerCard.count(); + + if (count > 0) { + // Get worker ID + const workerText = await workerCard.textContent(); + const workerMatch = workerText?.match(/w-[\w-]+/); + + if (workerMatch) { + const workerId = workerMatch[0]; + + // Select worker + await executeCommand(page, `worker:${workerId}`); + await page.waitForTimeout(200); + + // Set filter + await executeCommand(page, `filter:worker:${workerId}`); + await page.waitForTimeout(200); + + // Export + await executeCommand(page, 'export:link'); + await page.waitForTimeout(200); + + // Replay panel should be open + const replay = page.locator('.session-replay-panel'); + await expect(replay).toBeVisible(); + } + } + }); + + test('should support workflow: setup focus preset → save → load', async ({ page }) => { + await page.waitForTimeout(1000); + + const workerCard = page.locator('.worker-card').first(); + const count = await workerCard.count(); + + if (count >= 2) { + // Pin first worker + await workerCard.click(); + await page.waitForTimeout(100); + + // Pin second worker + const secondCard = page.locator('.worker-card').nth(1); + await secondCard.click(); + await page.waitForTimeout(100); + + // Enable focus mode + await executeCommand(page, 'focus:toggle'); + await page.waitForTimeout(200); + + // Save preset + await openCommandPalette(page); + await typeQuery(page, 'preset:save'); + await page.waitForTimeout(150); + await page.keyboard.press('Enter'); + + // Preset save dialog should open + const presetDialog = page.locator('.preset-dialog'); + const hasDialog = await presetDialog.count() > 0; + + if (hasDialog) { + await expect(presetDialog).toBeVisible(); + } + } + }); + + test('should support workflow: theme switch → view toggle → filter', async ({ page }) => { + // Switch to dark theme + await executeCommand(page, 'theme:dark'); + await page.waitForTimeout(200); + + // Show timeline + await executeCommand(page, 'show:timeline'); + await page.waitForTimeout(200); + + // Set filter + await executeCommand(page, 'filter:level:warn'); + await page.waitForTimeout(200); + + // All operations should complete + const timeline = page.locator('.timeline-view'); + await expect(timeline).toBeVisible(); + + const themeToggle = page.locator('.theme-toggle'); + await expect(themeToggle).toBeVisible(); + }); + }); + + test.describe('Keyboard Shortcuts in Workflows', () => { + test('should support keyboard-only workflow', async ({ page }) => { + // Complete workflow using only keyboard + await page.waitForTimeout(1000); + + // Open palette + await page.keyboard.press('Meta+k'); + await page.waitForTimeout(100); + + // Type command + await page.keyboard.type('theme:toggle'); + await page.waitForTimeout(200); + + // Execute + await page.keyboard.press('Enter'); + await page.waitForTimeout(200); + + // Open palette again + await page.keyboard.press('Meta+k'); + await page.waitForTimeout(100); + + // Should work + const palette = page.locator('.cp-overlay'); + await expect(palette).toBeVisible(); + + // Close + await page.keyboard.press('Escape'); + }); + + test('should support navigation and execution with keyboard', async ({ page }) => { + await openCommandPalette(page); + + // Type query + await typeQuery(page, 'show'); + + // Navigate down + await page.keyboard.press('ArrowDown'); + await page.waitForTimeout(50); + + // Navigate up + await page.keyboard.press('ArrowUp'); + await page.waitForTimeout(50); + + // Execute + await page.keyboard.press('Enter'); + + // Should close + const palette = page.locator('.cp-overlay'); + await expect(palette).not.toBeVisible(); + }); + }); +}); diff --git a/e2e/critical-flows.spec.ts b/e2e/critical-flows.spec.ts new file mode 100644 index 0000000..7a68352 --- /dev/null +++ b/e2e/critical-flows.spec.ts @@ -0,0 +1,626 @@ +/** + * E2E Tests: Critical User Flows - Integration + * + * Comprehensive integration tests for the most critical user workflows: + * - Flow 1: Worker selection and detail view navigation + * - Flow 2: WebSocket connection and real-time event streaming + * - Flow 3: Command palette search and execution + * - Flow 4: Focus mode pin/unpin operations + * + * These tests verify complete user journeys across multiple features. + */ + +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 { + await page.waitForSelector('.connection-status .status-dot.connected', { timeout }); +} + +/** + * Helper: Get worker count from UI + */ +async function getWorkerCount(page: Page): Promise { + const workerCards = page.locator('.worker-card'); + return await workerCards.count(); +} + +/** + * Helper: Get event count from activity stream + */ +async function getEventCount(page: Page): Promise { + const events = page.locator('.activity-stream .event-item, .activity-stream .event, .log-entry'); + return await events.count(); +} + +/** + * Helper: Open command palette + */ +async function openCommandPalette(page: Page): Promise { + 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 }); + await page.waitForTimeout(100); +} + +/** + * Helper: Execute command via palette + */ +async function executeCommand(page: Page, query: string): Promise { + await openCommandPalette(page); + const input = page.locator('.cp-input'); + await input.fill(query); + await page.waitForTimeout(150); + await page.keyboard.press('Enter'); + await page.waitForTimeout(200); +} + +test.describe('E2E: Critical User Flows - Integration', () => { + test.beforeEach(async ({ page }) => { + await page.goto(BASE_URL); + }); + + test.describe('Flow 1: Worker Selection and Detail View', () => { + test('should complete full worker inspection workflow', async ({ page }) => { + await waitForWebSocketConnection(page); + await page.waitForTimeout(1000); + + const workerCard = page.locator('.worker-card').first(); + const count = await workerCard.count(); + + if (count > 0) { + // Step 1: View initial state - no worker selected + const selectedBefore = await workerCard.evaluate(el => el.classList.contains('selected')); + expect(selectedBefore).toBeFalsy(); + + // Step 2: Click worker to select + await workerCard.click(); + await page.waitForTimeout(200); + + // Verify worker is selected + const selectedAfter = await workerCard.evaluate(el => el.classList.contains('selected')); + expect(selectedAfter).toBeTruthy(); + + // Step 3: Check if detail panel appears (if implemented) + const detailPanel = page.locator('.worker-detail-panel, .worker-detail'); + const hasDetail = await detailPanel.count() > 0; + + if (hasDetail) { + await expect(detailPanel).toBeVisible(); + } + + // Step 4: Verify activity stream filters to selected worker + const activityStream = page.locator('.activity-stream'); + await expect(activityStream).toBeVisible(); + + // Step 5: Deselect worker (Escape key) + await page.keyboard.press('Escape'); + await page.waitForTimeout(200); + + const selectedDeselected = await workerCard.evaluate(el => el.classList.contains('selected')); + expect(selectedDeselected).toBeFalsy(); + } + }); + + test('should navigate between multiple workers', async ({ page }) => { + await waitForWebSocketConnection(page); + await page.waitForTimeout(1000); + + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 2) { + // Select first worker + await workerCards.nth(0).click(); + await page.waitForTimeout(100); + + let firstSelected = await workerCards.nth(0).evaluate(el => el.classList.contains('selected')); + expect(firstSelected).toBeTruthy(); + + // Select second worker + await workerCards.nth(1).click(); + await page.waitForTimeout(100); + + // First worker should be deselected + firstSelected = await workerCards.nth(0).evaluate(el => el.classList.contains('selected')); + expect(firstSelected).toBeFalsy(); + + // Second worker should be selected + const secondSelected = await workerCards.nth(1).evaluate(el => el.classList.contains('selected')); + expect(secondSelected).toBeTruthy(); + } + }); + + test('should show worker status and metadata', async ({ page }) => { + await waitForWebSocketConnection(page); + await page.waitForTimeout(1000); + + const workerCard = page.locator('.worker-card').first(); + const count = await workerCard.count(); + + if (count > 0) { + // Worker card should be visible + await expect(workerCard).toBeVisible(); + + // Should contain worker ID + const text = await workerCard.textContent(); + expect(text).toMatch(/w-[\w-]+/); + + // Should have status indicator + const statusIndicator = workerCard.locator('.status, [class*="status"]'); + const hasStatus = await statusIndicator.count() > 0; + + if (hasStatus) { + await expect(statusIndicator.first()).toBeVisible(); + } + } + }); + }); + + test.describe('Flow 2: WebSocket Connection and Event Streaming', () => { + 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 and display events in real-time', async ({ page }) => { + await waitForWebSocketConnection(page); + + const initialEventCount = await getEventCount(page); + + // Wait for new events to arrive + await page.waitForTimeout(3000); + + const newEventCount = await getEventCount(page); + + // Event count should update (may stay same if no new events) + expect(newEventCount).toBeGreaterThanOrEqual(initialEventCount); + }); + + test('should show connection state changes', async ({ page }) => { + const connectionStatus = page.locator('.connection-status'); + await expect(connectionStatus).toBeVisible(); + + // Wait for connection + await waitForWebSocketConnection(page); + + // Should show connected state + const statusDot = page.locator('.status-dot.connected'); + await expect(statusDot).toBeVisible(); + }); + + test('should handle worker updates from WebSocket', async ({ page }) => { + await waitForWebSocketConnection(page); + + const workerGrid = page.locator('.worker-grid'); + await expect(workerGrid).toBeVisible(); + + const initialWorkerCount = await getWorkerCount(page); + + // Wait for potential updates + await page.waitForTimeout(2000); + + const newWorkerCount = await getWorkerCount(page); + + // Worker grid should be present + await expect(workerGrid).toBeVisible(); + }); + }); + + test.describe('Flow 3: Command Palette Search and Execution', () => { + test('should open, search, and execute command', async ({ page }) => { + // Step 1: Open command palette (Ctrl+K or Cmd+K) + await openCommandPalette(page); + + const palette = page.locator('.cp-overlay'); + await expect(palette).toBeVisible(); + + // Step 2: Type search query + const input = page.locator('.cp-input'); + await input.fill('theme'); + await page.waitForTimeout(200); + + // Should show filtered results + const commands = page.locator('.cp-item'); + const count = await commands.count(); + expect(count).toBeGreaterThan(0); + + // Step 3: Execute command (Enter key) + await page.keyboard.press('Enter'); + await page.waitForTimeout(200); + + // Palette should close + await expect(palette).not.toBeVisible(); + }); + + test('should navigate command suggestions with keyboard', async ({ page }) => { + await openCommandPalette(page); + + // Type search query + const input = page.locator('.cp-input'); + await input.fill('show'); + await page.waitForTimeout(200); + + const commands = page.locator('.cp-item'); + const initialCount = await commands.count(); + + if (initialCount > 0) { + // Navigate down + await page.keyboard.press('ArrowDown'); + await page.waitForTimeout(50); + + // Navigate up + await page.keyboard.press('ArrowUp'); + await page.waitForTimeout(50); + + // Commands should still be visible + await expect(commands.first()).toBeVisible(); + } + }); + + test('should close palette with Escape key', async ({ page }) => { + await openCommandPalette(page); + + const palette = page.locator('.cp-overlay'); + await expect(palette).toBeVisible(); + + // Press Escape + await page.keyboard.press('Escape'); + await page.waitForTimeout(100); + + // Palette should close + await expect(palette).not.toBeVisible(); + }); + + test('should show empty state for non-matching queries', async ({ page }) => { + await openCommandPalette(page); + + // Type non-matching query + const input = page.locator('.cp-input'); + await input.fill('xyznonexistent123456'); + await page.waitForTimeout(200); + + // Should show empty state + const emptyState = page.locator('.cp-empty'); + await expect(emptyState).toBeVisible(); + }); + + test('should execute worker selection command', 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 worker ID from card + const workerText = await workerCard.textContent(); + const workerMatch = workerText?.match(/w-[\w-]+/); + + if (workerMatch) { + const workerId = workerMatch[0]; + + // Execute worker selection command + await executeCommand(page, `worker:${workerId}`); + + // Worker should be selected + await expect(workerCard).toHaveClass(/selected/); + } + } + }); + }); + + test.describe('Flow 4: Focus Mode Pin/Unpin Operations', () => { + test('should enable focus mode and pin workers', async ({ page }) => { + await waitForWebSocketConnection(page); + await page.waitForTimeout(1000); + + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 2) { + // Step 1: Enable focus mode (if toggle exists) + const focusToggle = page.locator('.focus-mode-toggle'); + const hasFocusToggle = await focusToggle.count() > 0; + + if (hasFocusToggle) { + await focusToggle.click(); + await page.waitForTimeout(100); + + const isActive = await focusToggle.evaluate(el => el.classList.contains('active')); + expect(isActive).toBeTruthy(); + } + + // Step 2: Pin first worker + const firstCard = workerCards.nth(0); + const pinButton = firstCard.locator('.pin-button'); + const hasPinButton = await pinButton.count() > 0; + + if (hasPinButton) { + await pinButton.click(); + await page.waitForTimeout(100); + + const isPinned = await firstCard.evaluate(el => el.classList.contains('pinned')); + expect(isPinned).toBeTruthy(); + } + + // Step 3: Pin second worker + const secondCard = workerCards.nth(1); + const secondPinButton = secondCard.locator('.pin-button'); + + if (await secondPinButton.count() > 0) { + await secondPinButton.click(); + await page.waitForTimeout(100); + + const isPinned = await secondCard.evaluate(el => el.classList.contains('pinned')); + expect(isPinned).toBeTruthy(); + } + + // Step 4: Check count indicator + const countBadge = page.locator('.focus-mode-count'); + const hasCountBadge = await countBadge.count() > 0; + + if (hasCountBadge) { + await expect(countBadge).toBeVisible(); + const countText = await countBadge.textContent(); + expect(parseInt(countText || '0', 10)).toBeGreaterThan(0); + } + } + }); + + test('should toggle focus mode on and off', async ({ page }) => { + await waitForWebSocketConnection(page); + + const focusToggle = page.locator('.focus-mode-toggle'); + const hasFocusToggle = await focusToggle.count() > 0; + + if (hasFocusToggle) { + // Enable focus mode + await focusToggle.click(); + await page.waitForTimeout(100); + + let isActive = await focusToggle.evaluate(el => el.classList.contains('active')); + expect(isActive).toBeTruthy(); + + // Disable focus mode + await focusToggle.click(); + await page.waitForTimeout(100); + + isActive = await focusToggle.evaluate(el => el.classList.contains('active')); + expect(isActive).toBeFalsy(); + } + }); + + test('should unpin workers by clicking pin button again', async ({ page }) => { + await waitForWebSocketConnection(page); + await page.waitForTimeout(1000); + + const workerCard = page.locator('.worker-card').first(); + const count = await workerCard.count(); + + if (count > 0) { + const pinButton = workerCard.locator('.pin-button'); + const hasPinButton = await pinButton.count() > 0; + + if (hasPinButton) { + // Pin worker + await pinButton.click(); + await page.waitForTimeout(100); + + let isPinned = await workerCard.evaluate(el => el.classList.contains('pinned')); + expect(isPinned).toBeTruthy(); + + // Unpin worker + await pinButton.click(); + await page.waitForTimeout(100); + + isPinned = await workerCard.evaluate(el => el.classList.contains('pinned')); + expect(isPinned).toBeFalsy(); + } + } + }); + + test('should clear all pins via command palette', async ({ page }) => { + await waitForWebSocketConnection(page); + await page.waitForTimeout(1000); + + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 2) { + // Pin workers + for (let i = 0; i < Math.min(count, 2); i++) { + const pinButton = workerCards.nth(i).locator('.pin-button'); + if (await pinButton.count() > 0) { + await pinButton.click(); + await page.waitForTimeout(50); + } + } + + // Clear via command palette + await executeCommand(page, 'focus:clear'); + await page.waitForTimeout(200); + + // All workers should be unpinned + for (let i = 0; i < Math.min(count, 2); i++) { + const isPinned = await workerCards.nth(i).evaluate(el => el.classList.contains('pinned')); + expect(isPinned).toBeFalsy(); + } + } + }); + }); + + test.describe('Flow 5: Complete User Journey - End to End', () => { + test('should support complete inspection workflow', async ({ page }) => { + // Step 1: Page loads and WebSocket connects + const connectionStatus = page.locator('.connection-status'); + await expect(connectionStatus).toBeVisible(); + + await waitForWebSocketConnection(page); + + // Step 2: View worker grid + const workerGrid = page.locator('.worker-grid'); + await expect(workerGrid).toBeVisible(); + + await page.waitForTimeout(1000); + + const workerCard = page.locator('.worker-card').first(); + const count = await workerCard.count(); + + if (count > 0) { + // Step 3: Select worker + await workerCard.click(); + await page.waitForTimeout(200); + + const selected = await workerCard.evaluate(el => el.classList.contains('selected')); + expect(selected).toBeTruthy(); + + // Step 4: Open command palette + await openCommandPalette(page); + const palette = page.locator('.cp-overlay'); + await expect(palette).toBeVisible(); + + // Step 5: Execute a command + const input = page.locator('.cp-input'); + await input.fill('theme'); + await page.waitForTimeout(200); + await page.keyboard.press('Enter'); + await page.waitForTimeout(200); + + // Step 6: Close palette + await expect(palette).not.toBeVisible(); + + // Step 7: Enable focus mode + const focusToggle = page.locator('.focus-mode-toggle'); + const hasFocusToggle = await focusToggle.count() > 0; + + if (hasFocusToggle) { + await focusToggle.click(); + await page.waitForTimeout(100); + + const isActive = await focusToggle.evaluate(el => el.classList.contains('active')); + expect(isActive).toBeTruthy(); + } + + // Step 8: Verify activity stream is still visible + const activityStream = page.locator('.activity-stream'); + await expect(activityStream).toBeVisible(); + } + }); + + test('should handle rapid interactions without errors', async ({ page }) => { + await waitForWebSocketConnection(page); + + // Rapid sequence of interactions + for (let i = 0; i < 5; i++) { + // Open and close command palette + await openCommandPalette(page); + await page.waitForTimeout(50); + await page.keyboard.press('Escape'); + await page.waitForTimeout(50); + } + + // App should remain stable + const body = page.locator('body'); + await expect(body).toBeVisible(); + }); + + test('should preserve state across keyboard navigation', 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 with keyboard (Enter key) + await workerCard.focus(); + await page.keyboard.press('Enter'); + await page.waitForTimeout(200); + + const selected = await workerCard.evaluate(el => el.classList.contains('selected')); + expect(selected).toBeTruthy(); + + // Deselect with Escape + await page.keyboard.press('Escape'); + await page.waitForTimeout(200); + + const deselected = await workerCard.evaluate(el => el.classList.contains('selected')); + expect(deselected).toBeFalsy(); + } + }); + }); + + test.describe('Flow 6: Error Recovery and Resilience', () => { + test('should handle connection loss gracefully', async ({ page }) => { + await waitForWebSocketConnection(page); + + // Simulate network loss + await page.context().setOffline(true); + await page.waitForTimeout(1000); + + // Restore network + await page.context().setOffline(false); + await page.waitForTimeout(1000); + + // App should still be functional + const body = page.locator('body'); + await expect(body).toBeVisible(); + }); + + test('should recover from command palette errors', async ({ page }) => { + await openCommandPalette(page); + + // Type invalid command + const input = page.locator('.cp-input'); + await input.fill('invalid:command:that:does:not:exist'); + await page.waitForTimeout(200); + + // Press Enter - should close safely + await page.keyboard.press('Enter'); + await page.waitForTimeout(200); + + const palette = page.locator('.cp-overlay'); + await expect(palette).not.toBeVisible(); + + // App should remain functional + const body = page.locator('body'); + await expect(body).toBeVisible(); + }); + + test('should handle rapid worker selection changes', async ({ page }) => { + await waitForWebSocketConnection(page); + await page.waitForTimeout(1000); + + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 2) { + // Rapid worker selection + for (let i = 0; i < 5; i++) { + await workerCards.nth(i % count).click(); + await page.waitForTimeout(50); + } + + // Should remain stable + const workerGrid = page.locator('.worker-grid'); + await expect(workerGrid).toBeVisible(); + } + }); + }); +}); diff --git a/e2e/edge-cases.spec.ts b/e2e/edge-cases.spec.ts new file mode 100644 index 0000000..46935ae --- /dev/null +++ b/e2e/edge-cases.spec.ts @@ -0,0 +1,507 @@ +/** + * E2E Tests: Web Dashboard Edge Cases + * + * Tests for edge cases and error scenarios in the web dashboard: + * - Empty states + * - Error handling + * - Network resilience + * - Browser compatibility issues + * - Performance under load + */ + +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 { + await page.waitForSelector('.connection-status .status-dot.connected', { timeout }); +} + +/** + * Helper: Get current worker count + */ +async function getWorkerCount(page: Page): Promise { + const workerCards = page.locator('.worker-card'); + return await workerCards.count(); +} + +/** + * Helper: Get event count in activity stream + */ +async function getEventCount(page: Page): Promise { + const events = page.locator('.activity-stream .event, .log-entry'); + return await events.count(); +} + +test.describe('E2E: Web Dashboard Edge Cases', () => { + test.beforeEach(async ({ page }) => { + await page.goto(BASE_URL); + }); + + test.describe('Empty States', () => { + test('should show empty state when no workers are present', async ({ page }) => { + await waitForWebSocketConnection(page); + await page.waitForTimeout(1000); + + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count === 0) { + // Should show empty state message + const emptyState = page.locator('.empty-state, [class*="empty"], [class*="no-workers"]'); + const hasEmptyState = await emptyState.count() > 0; + + if (hasEmptyState) { + await expect(emptyState.first()).toBeVisible(); + } + } + }); + + test('should show empty state in activity stream when no events', async ({ page }) => { + await waitForWebSocketConnection(page); + await page.waitForTimeout(1000); + + const events = page.locator('.activity-stream .event, .log-entry'); + const count = await events.count(); + + if (count === 0) { + // Activity stream should still be visible + const activityStream = page.locator('.activity-stream'); + await expect(activityStream).toBeVisible(); + } + }); + + test('should show empty state in command palette when no results', async ({ page }) => { + // Open command palette + await page.keyboard.press('Meta+k'); + const 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 non-matching query + const input = page.locator('.cp-input'); + await input.fill('xyznonexistent123456'); + await page.waitForTimeout(200); + + // Should show empty state + const emptyState = page.locator('.cp-empty'); + await expect(emptyState).toBeVisible(); + await expect(emptyState).toContainText('No results'); + }); + }); + + test.describe('Error Handling', () => { + test('should handle malformed WebSocket messages gracefully', async ({ page }) => { + await waitForWebSocketConnection(page); + + // App should remain functional even with bad data + await page.waitForTimeout(1000); + + const connectionStatus = page.locator('.connection-status'); + await expect(connectionStatus).toBeVisible(); + }); + + test('should show error boundary when component fails', async ({ page }) => { + await waitForWebSocketConnection(page); + + // If there's a JavaScript error, the app should handle it gracefully + // The page should still be functional + const body = page.locator('body'); + await expect(body).toBeVisible(); + }); + + test('should handle API errors gracefully', async ({ page, request }) => { + // Try to access an invalid endpoint + const response = await request.get(`${BASE_URL}/api/invalid-endpoint`); + + // Should return 404, not crash + expect(response.status()).toBe(404); + }); + + test('should handle large payloads without freezing', async ({ page }) => { + await waitForWebSocketConnection(page); + + // Simulate large data load by waiting + await page.waitForTimeout(2000); + + // UI should remain responsive + const body = page.locator('body'); + await body.click(); + + const connectionStatus = page.locator('.connection-status'); + await expect(connectionStatus).toBeVisible(); + }); + }); + + test.describe('Network Resilience', () => { + test('should handle slow network connection', async ({ page }) => { + // Simulate slow network + await page.context().setOffline(false); + await page.route('**/*', async route => { + await new Promise(resolve => setTimeout(resolve, 100)); + return route.continue(); + }); + + await page.goto(BASE_URL); + + // Should still load eventually + const connectionStatus = page.locator('.connection-status'); + await expect(connectionStatus).toBeVisible({ timeout: 15000 }); + }); + + test('should recover from temporary network loss', async ({ page }) => { + await waitForWebSocketConnection(page); + + // Simulate network loss + await page.context().setOffline(true); + await page.waitForTimeout(2000); + + // Restore network + await page.context().setOffline(false); + + // Should reconnect + const connectionStatus = page.locator('.connection-status'); + await expect(connectionStatus).toBeVisible({ timeout: 15000 }); + }); + + test('should handle connection timeout gracefully', async ({ page }) => { + // Set a very short timeout + await page.goto(BASE_URL, { timeout: 5000 }).catch(() => { + // Timeout is expected + }); + + // Should show connection status regardless + const body = page.locator('body'); + await expect(body).toBeVisible(); + }); + }); + + test.describe('Performance Under Load', () => { + test('should handle rapid worker updates', async ({ page }) => { + await waitForWebSocketConnection(page); + + // Wait for multiple update cycles + for (let i = 0; i < 10; i++) { + await page.waitForTimeout(100); + const workerGrid = page.locator('.worker-grid'); + await expect(workerGrid).toBeVisible(); + } + }); + + test('should handle rapid event stream updates', async ({ page }) => { + await waitForWebSocketConnection(page); + + // Monitor activity stream during rapid updates + for (let i = 0; i < 10; i++) { + await page.waitForTimeout(100); + const activityStream = page.locator('.activity-stream'); + await expect(activityStream).toBeVisible(); + } + }); + + test('should remain responsive with many workers', async ({ page }) => { + await waitForWebSocketConnection(page); + await page.waitForTimeout(2000); + + const workerCount = await getWorkerCount(page); + + // UI should remain responsive regardless of worker count + const workerGrid = page.locator('.worker-grid'); + await expect(workerGrid).toBeVisible(); + + // Click should work + await workerGrid.click(); + await page.waitForTimeout(100); + }); + + test('should handle scrolling in activity stream with many events', async ({ page }) => { + await waitForWebSocketConnection(page); + await page.waitForTimeout(2000); + + 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 = 1000; + } + }); + + await page.waitForTimeout(100); + }); + }); + + test.describe('Browser Compatibility', () => { + test('should handle different viewport sizes', async ({ page }) => { + const viewports = [ + { width: 1920, height: 1080 }, + { width: 1366, height: 768 }, + { width: 768, height: 1024 }, + { width: 375, height: 667 }, + ]; + + for (const viewport of viewports) { + await page.setViewportSize(viewport); + await page.goto(BASE_URL); + + const body = page.locator('body'); + await expect(body).toBeVisible(); + + await page.waitForTimeout(500); + } + }); + + test('should handle browser back button navigation', async ({ page }) => { + await page.goto(BASE_URL); + await waitForWebSocketConnection(page); + + // Navigate away and back + await page.goto('about:blank'); + await page.waitForTimeout(500); + await page.goBack(); + + // Should reconnect + const connectionStatus = page.locator('.connection-status'); + await expect(connectionStatus).toBeVisible({ timeout: 15000 }); + }); + + test('should handle tab activation and deactivation', async ({ page }) => { + await page.goto(BASE_URL); + await waitForWebSocketConnection(page); + + // Simulate tab becoming inactive + await page.evaluate(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + + await page.waitForTimeout(1000); + + // Make tab active again + await page.evaluate(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + + await page.waitForTimeout(1000); + + // Should still be functional + const connectionStatus = page.locator('.connection-status'); + await expect(connectionStatus).toBeVisible(); + }); + }); + + test.describe('Input Validation', () => { + test('should handle special characters in search', async ({ page }) => { + // Open command palette + 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'); + + // Type special characters + const input = page.locator('.cp-input'); + const specialChars = ['!@#$%^&*()', '[]{}', '""\'\'', '<>']; + + for (const chars of specialChars) { + await input.fill(chars); + await page.waitForTimeout(100); + + // Should not crash + const palette = page.locator('.cp-overlay'); + await expect(palette).toBeVisible(); + } + }); + + test('should handle very long input in command palette', async ({ page }) => { + 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'); + + const input = page.locator('.cp-input'); + const longText = 'a'.repeat(1000); + + await input.fill(longText); + await page.waitForTimeout(200); + + // Should handle gracefully + const palette = page.locator('.cp-overlay'); + await expect(palette).toBeVisible(); + }); + + test('should handle rapid typing in command palette', async ({ page }) => { + 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'); + + const input = page.locator('.cp-input'); + + // Rapid typing + for (let i = 0; i < 20; i++) { + await input.fill(`test${i}`); + await page.waitForTimeout(50); + } + + // Should remain stable + const palette = page.locator('.cp-overlay'); + await expect(palette).toBeVisible(); + }); + }); + + test.describe('State Management', () => { + test('should preserve focus mode state across page reload', async ({ page }) => { + await waitForWebSocketConnection(page); + await page.waitForTimeout(1000); + + // Enable focus mode + const focusToggle = page.locator('.focus-mode-toggle'); + await focusToggle.click(); + await page.waitForTimeout(100); + + // Reload + await page.reload(); + await page.waitForLoadState('networkidle'); + + // Focus mode should still be enabled + await expect(focusToggle).toHaveClass(/active/); + }); + + test('should preserve theme selection across page reload', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + + // Toggle theme + const themeToggle = page.locator('.theme-toggle'); + await themeToggle.click(); + await page.waitForTimeout(100); + + // Reload + await page.reload(); + await page.waitForLoadState('networkidle'); + + // Theme toggle should still be visible + await expect(themeToggle).toBeVisible(); + }); + + test('should clear worker selection when navigating away', 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(100); + + // Navigate away + await page.goto('about:blank'); + await page.waitForTimeout(500); + + // Navigate back + await page.goBack(); + await page.waitForTimeout(1000); + + // Worker should be visible + const workerGrid = page.locator('.worker-grid'); + await expect(workerGrid).toBeVisible(); + } + }); + }); + + test.describe('Accessibility', () => { + test('should maintain keyboard focus during navigation', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + + // Tab through interface + for (let i = 0; i < 10; i++) { + await page.keyboard.press('Tab'); + await page.waitForTimeout(50); + + // Should not throw errors + const body = page.locator('body'); + await expect(body).toBeVisible(); + } + }); + + test('should show focus indicators on interactive elements', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + + // Focus on command palette button + const toggleButton = page.locator('.command-palette-toggle'); + await toggleButton.focus(); + await page.waitForTimeout(100); + + // Should have focus + await expect(toggleButton).toBeFocused(); + }); + + test('should support Escape key to close modals', async ({ page }) => { + // Open command palette + 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'); + + // Press Escape + await page.keyboard.press('Escape'); + + // Should close + const palette = page.locator('.cp-overlay'); + await expect(palette).not.toBeVisible(); + }); + }); + + test.describe('Memory Management', () => { + test('should not leak memory with rapid panel open/close', async ({ page }) => { + await waitForWebSocketConnection(page); + + // Rapid panel toggling + for (let i = 0; i < 20; i++) { + await page.keyboard.press('Meta+k'); + await page.waitForTimeout(50); + await page.keyboard.press('Escape'); + await page.waitForTimeout(50); + } + + // Should remain stable + const body = page.locator('body'); + await expect(body).toBeVisible(); + }); + + test('should clean up event listeners on unmount', async ({ page }) => { + await page.goto(BASE_URL); + await waitForWebSocketConnection(page); + + // Navigate away + await page.goto('about:blank'); + + // Should not throw errors + const body = page.locator('body'); + await expect(body).toBeVisible(); + }); + }); +}); diff --git a/e2e/focus-mode-multipin.spec.ts b/e2e/focus-mode-multipin.spec.ts new file mode 100644 index 0000000..df8dfe2 --- /dev/null +++ b/e2e/focus-mode-multipin.spec.ts @@ -0,0 +1,754 @@ +/** + * E2E Tests: Focus Mode with Multiple Pins + * + * Tests for Focus Mode functionality when multiple workers/beads are pinned: + * - Multiple worker pinning + * - Worker + bead combinations + * - Pin state management + * - Filter behavior with multiple pins + * - Preset management with multiple pins + */ + +import { test, expect, Page } from '@playwright/test'; + +const BASE_URL = 'http://localhost:3000'; + +/** + * Helper: Wait for workers to load + */ +async function waitForWorkers(page: Page): Promise { + await page.waitForTimeout(1000); +} + +/** + * Helper: Get pinned workers count from UI + */ +async function getPinnedCount(page: Page): Promise { + const countText = await page.locator('.focus-mode-count').textContent().catch(() => '0'); + return parseInt(countText || '0', 10); +} + +/** + * Helper: Check if focus mode is active + */ +async function isFocusModeActive(page: Page): Promise { + const toggle = page.locator('.focus-mode-toggle'); + const hasClass = await toggle.evaluate(el => el.classList.contains('active')); + return hasClass; +} + +/** + * Helper: Toggle focus mode + */ +async function toggleFocusMode(page: Page): Promise { + const toggle = page.locator('.focus-mode-toggle'); + await toggle.click(); + await page.waitForTimeout(100); +} + +/** + * Helper: Pin a worker by index + */ +async function pinWorker(page: Page, index: number): Promise { + const workerCard = page.locator('.worker-card').nth(index); + const pinButton = workerCard.locator('.pin-button'); + await pinButton.click(); + await page.waitForTimeout(100); +} + +/** + * Helper: Open command palette + */ +async function openCommandPalette(page: Page): Promise { + 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'); + await page.waitForTimeout(100); +} + +/** + * Helper: Type and execute command + */ +async function executeCommand(page: Page, query: string): Promise { + await openCommandPalette(page); + const input = page.locator('.cp-input'); + await input.fill(query); + await page.waitForTimeout(150); + await page.keyboard.press('Enter'); + await page.waitForTimeout(200); +} + +test.describe('E2E: Focus Mode with Multiple Pins', () => { + test.beforeEach(async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await waitForWorkers(page); + }); + + test.describe('Multiple Worker Pinning', () => { + test('should allow pinning multiple workers sequentially', async ({ page }) => { + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 3) { + // Pin first worker + await pinWorker(page, 0); + let pinnedCount = await getPinnedCount(page); + expect(pinnedCount).toBeGreaterThanOrEqual(1); + + // Pin second worker + await pinWorker(page, 1); + pinnedCount = await getPinnedCount(page); + expect(pinnedCount).toBeGreaterThanOrEqual(2); + + // Pin third worker + await pinWorker(page, 2); + pinnedCount = await getPinnedCount(page); + expect(pinnedCount).toBeGreaterThanOrEqual(3); + + // All should be pinned + for (let i = 0; i < 3; i++) { + const card = workerCards.nth(i); + await expect(card).toHaveClass(/pinned/); + } + } + }); + + test('should show correct count when multiple workers are pinned', async ({ page }) => { + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 3) { + // Pin three workers + await pinWorker(page, 0); + await pinWorker(page, 1); + await pinWorker(page, 2); + + const pinnedCount = await getPinnedCount(page); + expect(pinnedCount).toBe(3); + + // Count badge should show "3" + const countBadge = page.locator('.focus-mode-count'); + const countText = await countBadge.textContent(); + expect(parseInt(countText || '0', 10)).toBe(3); + } + }); + + test('should filter to show all pinned workers when focus mode is on', async ({ page }) => { + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 3) { + // Pin three workers + await pinWorker(page, 0); + await pinWorker(page, 1); + await pinWorker(page, 2); + + // Enable focus mode + await toggleFocusMode(page); + + // Should show all three pinned workers + const visibleCards = page.locator('.worker-card:not(.hidden)'); + const visibleCount = await visibleCards.count(); + + expect(visibleCount).toBe(3); + } + }); + + test('should allow unpinning individual workers when multiple are pinned', async ({ page }) => { + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 3) { + // Pin three workers + await pinWorker(page, 0); + await pinWorker(page, 1); + await pinWorker(page, 2); + + let pinnedCount = await getPinnedCount(page); + expect(pinnedCount).toBe(3); + + // Unpin middle worker + const middleCard = workerCards.nth(1); + const pinButton = middleCard.locator('.pin-button'); + await pinButton.click(); + await page.waitForTimeout(100); + + // Should now have 2 pinned + pinnedCount = await getPinnedCount(page); + expect(pinnedCount).toBe(2); + + // Middle worker should not be pinned + await expect(middleCard).not.toHaveClass(/pinned/); + } + }); + + test('should handle unpinning all workers one by one', async ({ page }) => { + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 2) { + // Pin two workers + await pinWorker(page, 0); + await pinWorker(page, 1); + + // Unpin first + const firstCard = workerCards.nth(0); + const firstPinButton = firstCard.locator('.pin-button'); + await firstPinButton.click(); + await page.waitForTimeout(100); + + // Unpin second + const secondCard = workerCards.nth(1); + const secondPinButton = secondCard.locator('.pin-button'); + await secondPinButton.click(); + await page.waitForTimeout(100); + + // Should have no pinned workers + const pinnedCount = await getPinnedCount(page); + expect(pinnedCount).toBe(0); + + // Count badge should be hidden or show 0 + const countBadge = page.locator('.focus-mode-count'); + const hasCount = await countBadge.count() > 0; + + if (hasCount) { + const countText = await countBadge.textContent(); + expect(countText).toBe('0'); + } + } + }); + }); + + test.describe('Worker and Bead Combinations', () => { + test('should allow pinning both workers and beads', async ({ page }) => { + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 2) { + // Pin first worker + await pinWorker(page, 0); + + // Pin a bead via command palette + await executeCommand(page, 'bead:bd-test123'); + + // Should have at least 2 pins (1 worker + 1 bead) + const pinnedCount = await getPinnedCount(page); + expect(pinnedCount).toBeGreaterThanOrEqual(2); + } + }); + + test('should filter by both pinned workers and beads', async ({ page }) => { + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 2) { + // Pin first worker + await pinWorker(page, 0); + + // Enable focus mode + await toggleFocusMode(page); + + // Should show only pinned worker + let visibleCards = page.locator('.worker-card:not(.hidden)'); + let visibleCount = await visibleCards.count(); + expect(visibleCount).toBeLessThanOrEqual(1); + + // Add a bead pin via command palette + await executeCommand(page, 'bead:bd-test'); + + // Should show worker with matching bead events + visibleCards = page.locator('.worker-card:not(.hidden)'); + visibleCount = await visibleCards.count(); + expect(visibleCount).toBeGreaterThanOrEqual(1); + } + }); + + test('should show correct count for worker + bead combinations', async ({ page }) => { + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 2) { + // Pin two workers + await pinWorker(page, 0); + await pinWorker(page, 1); + + // Add bead pin + await executeCommand(page, 'bead:bd-test'); + + // Count should reflect all pins + const pinnedCount = await getPinnedCount(page); + expect(pinnedCount).toBeGreaterThanOrEqual(3); + } + }); + }); + + test.describe('Pin State Management', () => { + test('should persist multiple pins across page reload', async ({ page }) => { + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 2) { + // Pin two workers + await pinWorker(page, 0); + await pinWorker(page, 1); + + const initialCount = await getPinnedCount(page); + + // Reload page + await page.reload(); + await page.waitForLoadState('networkidle'); + await waitForWorkers(page); + + // Pins should be preserved + const reloadedCount = await getPinnedCount(page); + expect(reloadedCount).toBe(initialCount); + + // Workers should still be pinned + const firstCard = workerCards.nth(0); + const secondCard = workerCards.nth(1); + + await expect(firstCard).toHaveClass(/pinned/); + await expect(secondCard).toHaveClass(/pinned/); + } + }); + + test('should persist focus mode state with multiple pins', async ({ page }) => { + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 2) { + // Pin workers and enable focus mode + await pinWorker(page, 0); + await pinWorker(page, 1); + await toggleFocusMode(page); + + // Reload + await page.reload(); + await page.waitForLoadState('networkidle'); + await waitForWorkers(page); + + // Focus mode should still be active + const isActive = await isFocusModeActive(page); + expect(isActive).toBeTruthy(); + + // Pins should be preserved + const pinnedCount = await getPinnedCount(page); + expect(pinnedCount).toBeGreaterThanOrEqual(2); + } + }); + + test('should clear all pins via command palette', async ({ page }) => { + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 2) { + // Pin multiple workers + await pinWorker(page, 0); + await pinWorker(page, 1); + + let pinnedCount = await getPinnedCount(page); + expect(pinnedCount).toBeGreaterThanOrEqual(2); + + // Clear via command palette + await executeCommand(page, 'focus:clear'); + await page.waitForTimeout(100); + + // All pins should be cleared + pinnedCount = await getPinnedCount(page); + expect(pinnedCount).toBe(0); + + // Workers should not be pinned + const firstCard = workerCards.nth(0); + const secondCard = workerCards.nth(1); + + await expect(firstCard).not.toHaveClass(/pinned/); + await expect(secondCard).not.toHaveClass(/pinned/); + } + }); + }); + + test.describe('Filter Behavior with Multiple Pins', () => { + test('should show only pinned workers in worker grid', async ({ page }) => { + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 3) { + // Pin first two workers + await pinWorker(page, 0); + await pinWorker(page, 1); + + // Enable focus mode + await toggleFocusMode(page); + + // Should only show pinned workers + const visibleCards = page.locator('.worker-card:not(.hidden)'); + const visibleCount = await visibleCards.count(); + + expect(visibleCount).toBe(2); + + // Third worker should be hidden + const thirdCard = workerCards.nth(2); + const isHidden = await thirdCard.evaluate(el => el.classList.contains('hidden')); + expect(isHidden).toBeTruthy(); + } + }); + + test('should filter activity stream to pinned workers', async ({ page }) => { + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 2) { + // Pin first worker + await pinWorker(page, 0); + + // Enable focus mode + await toggleFocusMode(page); + + // Activity stream should filter to pinned worker + const activityStream = page.locator('.activity-stream'); + await expect(activityStream).toBeVisible(); + + // Stream should show filtered indicator if implemented + const filterIndicator = page.locator('.filter-indicator, .filtered-indicator'); + const hasIndicator = await filterIndicator.count() > 0; + + if (hasIndicator) { + await expect(filterIndicator).toBeVisible(); + } + } + }); + + test('should update filter when pins are added/removed', async ({ page }) => { + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 3) { + // Pin first worker, enable focus mode + await pinWorker(page, 0); + await toggleFocusMode(page); + + let visibleCards = page.locator('.worker-card:not(.hidden)'); + let visibleCount = await visibleCards.count(); + expect(visibleCount).toBe(1); + + // Add second pin + await pinWorker(page, 1); + + visibleCards = page.locator('.worker-card:not(.hidden)'); + visibleCount = await visibleCards.count(); + expect(visibleCount).toBe(2); + + // Remove first pin + const firstCard = workerCards.nth(0); + const pinButton = firstCard.locator('.pin-button'); + await pinButton.click(); + await page.waitForTimeout(100); + + visibleCards = page.locator('.worker-card:not(.hidden)'); + visibleCount = await visibleCards.count(); + expect(visibleCount).toBe(1); + } + }); + }); + + test.describe('Preset Management with Multiple Pins', () => { + test('should save preset with multiple pinned workers', async ({ page }) => { + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 2) { + // Pin multiple workers + await pinWorker(page, 0); + await pinWorker(page, 1); + + // Open preset save dialog + await openCommandPalette(page); + const input = page.locator('.cp-input'); + await input.fill('preset:save'); + await page.waitForTimeout(150); + await page.keyboard.press('Enter'); + await page.waitForTimeout(200); + + // Dialog should open + const presetDialog = page.locator('.preset-dialog'); + const hasDialog = await presetDialog.count() > 0; + + if (hasDialog) { + await expect(presetDialog).toBeVisible(); + + // Type preset name + const nameInput = presetDialog.locator('input[type="text"]'); + await nameInput.fill('test-preset'); + + // Save + const saveButton = presetDialog.locator('button.primary'); + await saveButton.click(); + await page.waitForTimeout(100); + + // Dialog should close + await expect(presetDialog).not.toBeVisible(); + } + } + }); + + test('should load preset with multiple pinned workers', async ({ page }) => { + // First, assume a preset exists from previous test + // Open preset dropdown + const presetToggle = page.locator('.preset-toggle'); + const hasPreset = await presetToggle.count() > 0; + + if (hasPreset) { + await presetToggle.click(); + await page.waitForTimeout(100); + + // Check for saved presets + const presetItems = page.locator('.preset-item'); + const itemCount = await presetItems.count(); + + if (itemCount > 0) { + // Click first preset item (skip save button) + const firstPreset = presetItems.nth(1); + await firstPreset.click(); + await page.waitForTimeout(100); + + // Focus mode should be enabled + const isActive = await isFocusModeActive(page); + expect(isActive).toBeTruthy(); + + // Should have pinned workers + const pinnedCount = await getPinnedCount(page); + expect(pinnedCount).toBeGreaterThan(0); + } + } + }); + + test('should delete preset', async ({ page }) => { + const presetToggle = page.locator('.preset-toggle'); + const hasPreset = await presetToggle.count() > 0; + + if (hasPreset) { + await presetToggle.click(); + await page.waitForTimeout(100); + + // Look for delete button on preset items + const deleteButton = page.locator('.preset-delete'); + const hasDelete = await deleteButton.count() > 0; + + if (hasDelete) { + const initialCount = await page.locator('.preset-item').count(); + + // Click first delete button + await deleteButton.first().click(); + await page.waitForTimeout(100); + + // Count should decrease + const newCount = await page.locator('.preset-item').count(); + expect(newCount).toBeLessThan(initialCount); + } + } + }); + }); + + test.describe('Edge Cases with Multiple Pins', () => { + test('should handle pinning all available workers', async ({ page }) => { + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count > 0 && count <= 5) { + // Pin all workers + for (let i = 0; i < count; i++) { + await pinWorker(page, i); + } + + const pinnedCount = await getPinnedCount(page); + expect(pinnedCount).toBe(count); + + // Enable focus mode + await toggleFocusMode(page); + + // All workers should still be visible + const visibleCards = page.locator('.worker-card:not(.hidden)'); + const visibleCount = await visibleCards.count(); + expect(visibleCount).toBe(count); + } + }); + + test('should handle rapid pin/unpin operations on multiple workers', async ({ page }) => { + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 2) { + // Rapid pin/unpin cycles + for (let i = 0; i < 3; i++) { + await pinWorker(page, 0); + await pinWorker(page, 1); + + const firstCard = workerCards.nth(0); + const secondCard = workerCards.nth(1); + + await firstCard.locator('.pin-button').click(); + await page.waitForTimeout(50); + + await secondCard.locator('.pin-button').click(); + await page.waitForTimeout(50); + } + + // Should remain stable + const body = page.locator('body'); + await expect(body).toBeVisible(); + } + }); + + test('should handle pinning when focus mode is already active', async ({ page }) => { + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 2) { + // Enable focus mode first + await toggleFocusMode(page); + + // Pin workers while focus mode is active + await pinWorker(page, 0); + await pinWorker(page, 1); + + // Workers should become visible as they're pinned + const visibleCards = page.locator('.worker-card:not(.hidden)'); + const visibleCount = await visibleCards.count(); + + expect(visibleCount).toBe(2); + } + }); + + test('should handle unpinning when focus mode is active', async ({ page }) => { + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 2) { + // Pin workers and enable focus mode + await pinWorker(page, 0); + await pinWorker(page, 1); + await toggleFocusMode(page); + + let visibleCards = page.locator('.worker-card:not(.hidden)'); + let visibleCount = await visibleCards.count(); + expect(visibleCount).toBe(2); + + // Unpin one worker + const firstCard = workerCards.nth(0); + await firstCard.locator('.pin-button').click(); + await page.waitForTimeout(100); + + // Should update view + visibleCards = page.locator('.worker-card:not(.hidden)'); + visibleCount = await visibleCards.count(); + expect(visibleCount).toBe(1); + } + }); + + test('should handle clearing all pins when focus mode is active', async ({ page }) => { + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 2) { + // Pin workers and enable focus mode + await pinWorker(page, 0); + await pinWorker(page, 1); + await toggleFocusMode(page); + + // Clear all pins + await executeCommand(page, 'focus:clear'); + await page.waitForTimeout(100); + + // Should show empty state + const emptyState = page.locator('.worker-grid .empty-state'); + const hasEmptyState = await emptyState.count() > 0; + + if (hasEmptyState) { + await expect(emptyState).toBeVisible(); + } + + // No workers should be pinned + const pinnedCount = await getPinnedCount(page); + expect(pinnedCount).toBe(0); + } + }); + }); + + test.describe('Visual Feedback with Multiple Pins', () => { + test('should show pinned indicator on all pinned workers', async ({ page }) => { + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 3) { + // Pin multiple workers + await pinWorker(page, 0); + await pinWorker(page, 1); + await pinWorker(page, 2); + + // All should have pinned class + for (let i = 0; i < 3; i++) { + const card = workerCards.nth(i); + await expect(card).toHaveClass(/pinned/); + } + + // Unpinned workers should not have pinned class + if (count > 3) { + const unpinnedCard = workerCards.nth(3); + await expect(unpinnedCard).not.toHaveClass(/pinned/); + } + } + }); + + test('should show focus mode toggle with active state when pins exist', async ({ page }) => { + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 2) { + // Pin workers + await pinWorker(page, 0); + await pinWorker(page, 1); + + // Enable focus mode + await toggleFocusMode(page); + + // Toggle should be active + const focusToggle = page.locator('.focus-mode-toggle'); + await expect(focusToggle).toHaveClass(/active/); + + // Count should show 2 + const countBadge = page.locator('.focus-mode-count'); + await expect(countBadge).toBeVisible(); + + const countText = await countBadge.textContent(); + expect(parseInt(countText || '0', 10)).toBe(2); + } + }); + + test('should update pin count badge in real-time', async ({ page }) => { + const workerCards = page.locator('.worker-card'); + const count = await workerCards.count(); + + if (count >= 3) { + // Pin workers one by one + await pinWorker(page, 0); + + let countText = await page.locator('.focus-mode-count').textContent(); + expect(parseInt(countText || '0', 10)).toBe(1); + + await pinWorker(page, 1); + + countText = await page.locator('.focus-mode-count').textContent(); + expect(parseInt(countText || '0', 10)).toBe(2); + + await pinWorker(page, 2); + + countText = await page.locator('.focus-mode-count').textContent(); + expect(parseInt(countText || '0', 10)).toBe(3); + } + }); + }); +}); diff --git a/e2e/websocket-event-streaming.spec.ts b/e2e/websocket-event-streaming.spec.ts new file mode 100644 index 0000000..f9b57d5 --- /dev/null +++ b/e2e/websocket-event-streaming.spec.ts @@ -0,0 +1,567 @@ +/** + * 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 { + await page.waitForSelector('.connection-status .status-dot.connected', { timeout }); +} + +/** + * Helper: Get event count from activity stream + */ +async function getEventCount(page: Page): Promise { + 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 { + const workerCards = page.locator('.worker-card'); + return await workerCards.count(); +} + +/** + * Helper: Get text content of last event + */ +async function getLastEventText(page: Page): Promise { + 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(); + 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(); + } + }); + }); +}); diff --git a/e2e/websocket-reconnection.spec.ts b/e2e/websocket-reconnection.spec.ts new file mode 100644 index 0000000..3b68bb2 --- /dev/null +++ b/e2e/websocket-reconnection.spec.ts @@ -0,0 +1,443 @@ +/** + * E2E Tests: WebSocket Reconnection Scenarios + * + * Comprehensive tests for WebSocket reconnection behavior including: + * - Exponential backoff verification + * - Max retry behavior + * - Manual reconnect after max retries + * - Connection state transitions + * - Reconnection during active operations + */ + +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 { + await page.waitForSelector('.connection-status .status-dot.connected', { timeout }); +} + +/** + * Helper: Get connection state from UI + */ +async function getConnectionState(page: Page): Promise<{ state: string; attemptCount?: number; nextRetryIn?: number }> { + const statusDot = page.locator('.status-dot'); + const hasConnectedClass = await statusDot.evaluate(el => el.classList.contains('connected')); + const hasReconnectingClass = await statusDot.evaluate(el => el.classList.contains('reconnecting')); + const hasDisconnectedClass = await statusDot.evaluate(el => el.classList.contains('disconnected')); + + if (hasConnectedClass) return { state: 'connected' }; + if (hasReconnectingClass) { + const attemptText = await page.locator('.attempt-count').textContent().catch(() => ''); + const retryText = await page.locator('.retry-countdown').textContent().catch(() => ''); + return { + state: 'reconnecting', + attemptCount: attemptText ? parseInt(attemptText.match(/\d+/)?.[0] || '0', 10) : undefined, + nextRetryIn: retryText ? parseInt(retryText, 10) : undefined, + }; + } + if (hasDisconnectedClass) return { state: 'disconnected' }; + + return { state: 'unknown' }; +} + +test.describe('E2E: WebSocket Reconnection Scenarios', () => { + test.beforeEach(async ({ page }) => { + await page.goto(BASE_URL); + }); + + test.describe('Connection State Transitions', () => { + test('should transition from disconnected to connected', async ({ page }) => { + // Initial state should be disconnected or connecting + const initialState = await getConnectionState(page); + expect(['disconnected', 'connecting', 'reconnecting']).toContain(initialState.state); + + // Should eventually connect + await waitForWebSocketConnection(page); + + const finalState = await getConnectionState(page); + expect(finalState.state).toBe('connected'); + }); + + test('should show connecting state during initial connection', async ({ page }) => { + // Page starts, connection not yet established + const connectionStatus = page.locator('.connection-status'); + await expect(connectionStatus).toBeVisible(); + + // Wait for connection + await waitForWebSocketConnection(page); + + // Should now show connected + const statusDot = page.locator('.status-dot.connected'); + await expect(statusDot).toBeVisible(); + }); + + test('should update UI when connection state changes', async ({ page }) => { + const connectionStatus = page.locator('.connection-status'); + + // Initial state + await expect(connectionStatus).toBeVisible(); + + // Connected state + await waitForWebSocketConnection(page); + + const connectedText = await connectionStatus.textContent(); + expect(connectedText?.toLowerCase()).toContain('connected'); + }); + }); + + test.describe('Reconnection Behavior', () => { + test('should attempt reconnection after connection loss', async ({ page }) => { + await waitForWebSocketConnection(page); + + // Connection is established + let state = await getConnectionState(page); + expect(state.state).toBe('connected'); + + // Note: Actual disconnection simulation requires server cooperation + // This test verifies the UI has reconnection elements + + const reconnectingText = page.locator('.reconnecting-text'); + const hasReconnecting = await reconnectingText.count() > 0; + + if (hasReconnecting) { + await expect(reconnectingText).toBeVisible(); + } + }); + + test('should display retry countdown during reconnection', async ({ page }) => { + await waitForWebSocketConnection(page); + + // Check for countdown element + const retryCountdown = page.locator('.retry-countdown'); + const exists = await retryCountdown.count() > 0; + + if (exists) { + await expect(retryCountdown).toBeVisible(); + + // Should contain a number + const text = await retryCountdown.textContent(); + expect(text).toMatch(/\d+/); + } + }); + + test('should show attempt count during reconnection', async ({ page }) => { + await waitForWebSocketConnection(page); + + const attemptCount = page.locator('.attempt-count'); + const exists = await attemptCount.count() > 0; + + if (exists) { + await expect(attemptCount).toBeVisible(); + + // Should be in format [N] + const text = await attemptCount.textContent(); + expect(text).toMatch(/\[\d+\]/); + } + }); + + test('should use exponential backoff for retries', async ({ page }) => { + await waitForWebSocketConnection(page); + + // Verify exponential backoff elements exist + const retryCountdown = page.locator('.retry-countdown'); + const attemptCount = page.locator('.attempt-count'); + + const hasCountdown = await retryCountdown.count() > 0; + const hasAttemptCount = await attemptCount.count() > 0; + + // At least one of these should exist for exponential backoff display + expect(hasCountdown || hasAttemptCount).toBeTruthy(); + }); + }); + + test.describe('Max Retry Behavior', () => { + test('should show manual reconnect button after max retries', async ({ page }) => { + await waitForWebSocketConnection(page); + + // Check for reconnect button + const reconnectButton = page.locator('.reconnect-button'); + const exists = await reconnectButton.count() > 0; + + if (exists) { + await expect(reconnectButton).toBeVisible(); + await expect(reconnectButton).toHaveText(/Retry|Reconnect/i); + } + }); + + test('should stop automatic retries after max attempts', async ({ page }) => { + await waitForWebSocketConnection(page); + + // If in disconnected state with max retries reached + const reconnectButton = page.locator('.reconnect-button'); + const exists = await reconnectButton.count() > 0; + + if (exists) { + // Should show manual reconnect button + await expect(reconnectButton).toBeVisible(); + + // Should not show automatic countdown + const reconnectingText = page.locator('.reconnecting-text'); + const hasReconnectingText = await reconnectingText.count() > 0; + + if (hasReconnectingText) { + const text = await reconnectingText.textContent(); + // Should not have countdown if max retries reached + expect(text).not.toContain('Reconnecting...'); + } + } + }); + + test('should allow manual reconnect via button click', async ({ page }) => { + await waitForWebSocketConnection(page); + + const reconnectButton = page.locator('.reconnect-button'); + const exists = await reconnectButton.count() > 0; + + if (exists) { + // Click reconnect button + await reconnectButton.click(); + + // Should trigger reconnection attempt + await page.waitForTimeout(500); + + // Connection status should update + const connectionStatus = page.locator('.connection-status'); + await expect(connectionStatus).toBeVisible(); + } + }); + }); + + test.describe('Reconnection During Operations', () => { + test('should preserve selected worker during reconnection', 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 a worker + await workerCard.click(); + await page.waitForTimeout(100); + + // Verify worker is selected + await expect(workerCard).toHaveClass(/selected/); + + // Connection might be lost and reconnect + // Worker selection should be preserved + await page.waitForTimeout(2000); + + // Worker should still be selected (if still in DOM) + const stillVisible = await workerCard.isVisible().catch(() => false); + if (stillVisible) { + await expect(workerCard).toHaveClass(/selected/); + } + } + }); + + test('should preserve command palette state during reconnection', async ({ page }) => { + await waitForWebSocketConnection(page); + + // Open command palette + 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'); + + // Type a query + const input = page.locator('.cp-input'); + await input.fill('test'); + await page.waitForTimeout(100); + + // Connection might be lost + // Command palette should remain open + const palette = page.locator('.cp-overlay'); + await expect(palette).toBeVisible(); + }); + + test('should preserve focus mode state during reconnection', async ({ page }) => { + await waitForWebSocketConnection(page); + + // Enable focus mode + const focusToggle = page.locator('.focus-mode-toggle'); + await focusToggle.click(); + await page.waitForTimeout(100); + + const isActive = await focusToggle.evaluate(el => el.classList.contains('active')); + expect(isActive).toBeTruthy(); + + // Connection might be lost and reconnect + await page.waitForTimeout(2000); + + // Focus mode should still be active + await expect(focusToggle).toHaveClass(/active/); + }); + }); + + test.describe('Connection Quality Indicators', () => { + test('should display connection status with correct styling', async ({ page }) => { + await waitForWebSocketConnection(page); + + const statusDot = page.locator('.status-dot'); + + // Should have connected class + await expect(statusDot).toHaveClass(/connected/); + + // Should not have error or warning classes + await expect(statusDot).not.toHaveClass(/error|warning|disconnected/); + }); + + test('should update status indicator when connection changes', async ({ page }) => { + const connectionStatus = page.locator('.connection-status'); + + // Should be visible throughout + await expect(connectionStatus).toBeVisible(); + + // Wait for connection + await waitForWebSocketConnection(page); + + // Should show connected state + const statusDot = page.locator('.status-dot.connected'); + await expect(statusDot).toBeVisible(); + }); + + test('should show connection quality if available', async ({ page }) => { + await waitForWebSocketConnection(page); + + // Check for latency indicator + const latencyIndicator = page.locator('.connection-latency, .connection-quality'); + const hasLatency = await latencyIndicator.count() > 0; + + if (hasLatency) { + await expect(latencyIndicator).toBeVisible(); + } + }); + }); + + test.describe('Error Recovery', () => { + test('should recover from WebSocket errors', async ({ page }) => { + await waitForWebSocketConnection(page); + + // Simulate potential error condition + // App should remain functional + const body = page.locator('body'); + await expect(body).toBeVisible(); + + // Connection status should still be shown + const connectionStatus = page.locator('.connection-status'); + await expect(connectionStatus).toBeVisible(); + }); + + test('should handle malformed messages during reconnection', async ({ page }) => { + await waitForWebSocketConnection(page); + + // App should handle bad data gracefully + await page.waitForTimeout(1000); + + // Should still be functional + const workerGrid = page.locator('.worker-grid'); + await expect(workerGrid).toBeVisible(); + }); + + test('should maintain application state during reconnection', async ({ page }) => { + await waitForWebSocketConnection(page); + await page.waitForTimeout(1000); + + // Get initial worker count + const initialCount = await getWorkerCount(page); + + // Connection might reconnect + await page.waitForTimeout(2000); + + // Application state should be maintained + const workerGrid = page.locator('.worker-grid'); + await expect(workerGrid).toBeVisible(); + }); + }); + + test.describe('Concurrent Connection Handling', () => { + test('should handle multiple tabs connecting simultaneously', async ({ context }) => { + const pages: Page[] = []; + + try { + // Create multiple pages + for (let i = 0; i < 3; i++) { + const page = await context.newPage(); + await page.goto(BASE_URL); + pages.push(page); + } + + // All should connect + for (const page of pages) { + await page.waitForSelector('.connection-status', { timeout: 10000 }); + const connectionStatus = page.locator('.connection-status'); + await expect(connectionStatus).toBeVisible(); + } + } finally { + // Clean up pages + for (const page of pages) { + await page.close(); + } + } + }); + + test('should handle rapid connection attempts', async ({ page }) => { + // Rapid page reloads + for (let i = 0; i < 3; i++) { + await page.goto(BASE_URL); + await page.waitForTimeout(500); + } + + // Should still connect + const connectionStatus = page.locator('.connection-status'); + await expect(connectionStatus).toBeVisible({ timeout: 15000 }); + }); + }); + + test.describe('Network Conditions', () => { + test('should handle intermittent network connectivity', async ({ page }) => { + await waitForWebSocketConnection(page); + + // Simulate intermittent connectivity + for (let i = 0; i < 3; i++) { + await page.context().setOffline(true); + await page.waitForTimeout(500); + await page.context().setOffline(false); + await page.waitForTimeout(1000); + } + + // Should recover + const connectionStatus = page.locator('.connection-status'); + await expect(connectionStatus).toBeVisible(); + }); + + test('should handle very slow reconnection', async ({ page }) => { + await page.goto(BASE_URL); + + // Simulate slow network + await page.route('**/*', async route => { + await new Promise(resolve => setTimeout(resolve, 2000)); + return route.continue(); + }); + + // Should still load (eventually) + const body = page.locator('body'); + await expect(body).toBeVisible({ timeout: 30000 }); + }); + }); +}); + +/** + * Helper function to get worker count + */ +async function getWorkerCount(page: Page): Promise { + const workerCards = page.locator('.worker-card'); + return await workerCards.count(); +} diff --git a/package.json b/package.json index a72bc12..72aafc4 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,11 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:debug": "playwright test --debug", + "test:e2e:headed": "playwright test --headed" }, "keywords": [ "needle",