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