FABRIC/e2e/critical-flows.spec.ts
jedarden ff81b91097 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>
2026-04-28 14:28:30 -04:00

626 lines
20 KiB
TypeScript

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