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>
507 lines
16 KiB
TypeScript
507 lines
16 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|
|
});
|