// ----------------------------------------------------------------------------- // workflow-visualizer.visual.spec.ts // Sprint: SPRINT_20260117_032_ReleaseOrchestrator_workflow_visualization // Task: TASK-032-12 - Visual Regression Tests for DAG Visualization // Description: Playwright visual regression tests for workflow visualization // ----------------------------------------------------------------------------- import { test, expect } from '@playwright/test'; test.describe('Workflow DAG Visualization', () => { test.beforeEach(async ({ page }) => { // Navigate to test workflow page await page.goto('/workflows/test-run-001'); await page.waitForSelector('.workflow-visualizer'); }); test.describe('Node Rendering', () => { test('renders nodes at various complexities', async ({ page }) => { // Test with 10 nodes await page.goto('/workflows/test-10-nodes'); await page.waitForSelector('.node', { timeout: 5000 }); await expect(page.locator('.node')).toHaveCount(10); await expect(page.locator('.dag-canvas')).toHaveScreenshot('dag-10-nodes.png'); // Test with 50 nodes await page.goto('/workflows/test-50-nodes'); await page.waitForSelector('.node', { timeout: 10000 }); await expect(page.locator('.node')).toHaveCount(50); await expect(page.locator('.dag-canvas')).toHaveScreenshot('dag-50-nodes.png'); }); test('renders large workflow (100+ nodes) with acceptable performance', async ({ page }) => { const startTime = Date.now(); await page.goto('/workflows/test-100-nodes'); await page.waitForSelector('.node', { timeout: 15000 }); const loadTime = Date.now() - startTime; expect(loadTime).toBeLessThan(10000); // Should load within 10 seconds await expect(page.locator('.node')).toHaveCount(100); await expect(page.locator('.dag-canvas')).toHaveScreenshot('dag-100-nodes.png', { maxDiffPixelRatio: 0.05 // Allow 5% variance for large graphs }); }); }); test.describe('Node Status States', () => { test('displays pending node correctly', async ({ page }) => { await page.goto('/workflows/test-pending'); const pendingNode = page.locator('.node-pending').first(); await expect(pendingNode).toBeVisible(); await expect(pendingNode).toHaveScreenshot('node-pending.png'); }); test('displays running node with animation', async ({ page }) => { await page.goto('/workflows/test-running'); const runningNode = page.locator('.node-running').first(); await expect(runningNode).toBeVisible(); // Check for pulse animation const statusIndicator = runningNode.locator('.pulse'); await expect(statusIndicator).toBeVisible(); // Capture animation frame await expect(runningNode).toHaveScreenshot('node-running.png'); }); test('displays succeeded node correctly', async ({ page }) => { await page.goto('/workflows/test-succeeded'); const succeededNode = page.locator('.node-succeeded').first(); await expect(succeededNode).toBeVisible(); await expect(succeededNode).toHaveScreenshot('node-succeeded.png'); }); test('displays failed node correctly', async ({ page }) => { await page.goto('/workflows/test-failed'); const failedNode = page.locator('.node-failed').first(); await expect(failedNode).toBeVisible(); await expect(failedNode).toHaveScreenshot('node-failed.png'); }); test('displays skipped node correctly', async ({ page }) => { await page.goto('/workflows/test-skipped'); const skippedNode = page.locator('.node-skipped').first(); await expect(skippedNode).toBeVisible(); await expect(skippedNode).toHaveScreenshot('node-skipped.png'); }); test('displays cancelled node correctly', async ({ page }) => { await page.goto('/workflows/test-cancelled'); const cancelledNode = page.locator('.node-cancelled').first(); await expect(cancelledNode).toBeVisible(); await expect(cancelledNode).toHaveScreenshot('node-cancelled.png'); }); test('node state transition animation', async ({ page }) => { await page.goto('/workflows/test-transition'); // Initial state - pending const node = page.locator('[data-step-id="step-1"]'); await expect(node).toHaveClass(/node-pending/); // Trigger transition await page.click('[data-action="start-workflow"]'); // Wait for running state await expect(node).toHaveClass(/node-running/, { timeout: 5000 }); await expect(node).toHaveScreenshot('node-transition-running.png'); // Wait for completed state await expect(node).toHaveClass(/node-succeeded/, { timeout: 10000 }); await expect(node).toHaveScreenshot('node-transition-succeeded.png'); }); }); test.describe('Edge Rendering', () => { test('renders static edges correctly', async ({ page }) => { await page.goto('/workflows/test-static'); const edges = page.locator('.edge-path'); await expect(edges.first()).toBeVisible(); await expect(page.locator('.edges-layer')).toHaveScreenshot('edges-static.png'); }); test('renders animated edges for in-progress steps', async ({ page }) => { await page.goto('/workflows/test-running'); const animatedEdge = page.locator('.edge.animated'); await expect(animatedEdge).toBeVisible(); // Verify animation is present (dash animation) const edgePath = animatedEdge.locator('.edge-path'); const strokeDasharray = await edgePath.evaluate(el => window.getComputedStyle(el).getPropertyValue('stroke-dasharray') ); expect(strokeDasharray).not.toBe('none'); }); test('highlights critical path edges', async ({ page }) => { await page.goto('/workflows/test-completed'); // Enable critical path await page.click('button:has-text("Critical Path")'); const criticalEdge = page.locator('.edge.critical'); await expect(criticalEdge.first()).toBeVisible(); await expect(page.locator('.edges-layer')).toHaveScreenshot('edges-critical-path.png'); }); }); test.describe('Layout Algorithms', () => { test('dagre layout renders correctly', async ({ page }) => { await page.goto('/workflows/test-layout'); await page.selectOption('.layout-selector select', 'dagre'); await page.waitForTimeout(500); // Wait for layout animation await expect(page.locator('.dag-canvas')).toHaveScreenshot('layout-dagre.png'); }); test('elk layout renders correctly', async ({ page }) => { await page.goto('/workflows/test-layout'); await page.selectOption('.layout-selector select', 'elk'); await page.waitForTimeout(500); await expect(page.locator('.dag-canvas')).toHaveScreenshot('layout-elk.png'); }); test('force-directed layout renders correctly', async ({ page }) => { await page.goto('/workflows/test-layout'); await page.selectOption('.layout-selector select', 'force'); await page.waitForTimeout(1000); // Force layout needs more time await expect(page.locator('.dag-canvas')).toHaveScreenshot('layout-force.png', { maxDiffPixelRatio: 0.1 // Force layout may have slight variations }); }); }); test.describe('Zoom and Pan', () => { test('zoom controls work correctly', async ({ page }) => { await page.goto('/workflows/test-10-nodes'); // Zoom in await page.click('button[title="Zoom In"]'); await page.click('button[title="Zoom In"]'); await expect(page.locator('.zoom-label')).toContainText('150%'); await expect(page.locator('.dag-canvas')).toHaveScreenshot('zoom-150.png'); // Zoom out await page.click('button[title="Zoom Out"]'); await page.click('button[title="Zoom Out"]'); await page.click('button[title="Zoom Out"]'); await expect(page.locator('.zoom-label')).toContainText('75%'); await expect(page.locator('.dag-canvas')).toHaveScreenshot('zoom-75.png'); }); test('fit to view resets viewport', async ({ page }) => { await page.goto('/workflows/test-10-nodes'); // Pan and zoom await page.click('button[title="Zoom In"]'); await page.click('button[title="Zoom In"]'); // Fit to view await page.click('button[title="Fit to View"]'); await expect(page.locator('.zoom-label')).toContainText('100%'); await expect(page.locator('.dag-canvas')).toHaveScreenshot('zoom-fit.png'); }); test('mouse wheel zooms', async ({ page }) => { await page.goto('/workflows/test-10-nodes'); const canvas = page.locator('.canvas-container'); // Scroll to zoom await canvas.hover(); await page.mouse.wheel(0, -100); // Zoom in await page.waitForTimeout(200); const zoomLabel = await page.locator('.zoom-label').textContent(); expect(parseInt(zoomLabel || '100')).toBeGreaterThan(100); }); test('drag to pan', async ({ page }) => { await page.goto('/workflows/test-50-nodes'); const canvas = page.locator('.canvas-container'); // Get initial viewbox const initialViewBox = await page.locator('.dag-canvas').getAttribute('viewBox'); // Drag to pan const box = await canvas.boundingBox(); if (box) { await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down(); await page.mouse.move(box.x + box.width / 2 + 100, box.y + box.height / 2 + 50); await page.mouse.up(); } // Viewbox should have changed const newViewBox = await page.locator('.dag-canvas').getAttribute('viewBox'); expect(newViewBox).not.toBe(initialViewBox); }); }); test.describe('Node Selection', () => { test('clicking node selects it', async ({ page }) => { await page.goto('/workflows/test-10-nodes'); const node = page.locator('.node').first(); await node.click(); await expect(node).toHaveClass(/selected/); await expect(node).toHaveScreenshot('node-selected.png'); }); test('double-clicking node opens details', async ({ page }) => { await page.goto('/workflows/test-10-nodes'); const node = page.locator('.node').first(); await node.dblclick(); await expect(page.locator('.step-detail-panel')).toBeVisible(); }); }); test.describe('Minimap', () => { test('minimap renders for large workflows', async ({ page }) => { await page.goto('/workflows/test-50-nodes'); await expect(page.locator('.minimap')).toBeVisible(); await expect(page.locator('.minimap')).toHaveScreenshot('minimap.png'); }); test('minimap shows viewport indicator', async ({ page }) => { await page.goto('/workflows/test-50-nodes'); await expect(page.locator('.viewport-indicator')).toBeVisible(); }); test('minimap hidden for small workflows', async ({ page }) => { await page.goto('/workflows/test-5-nodes'); await expect(page.locator('.minimap')).not.toBeVisible(); }); }); test.describe('Responsive Layout', () => { test('mobile viewport adjusts layout', async ({ page }) => { await page.setViewportSize({ width: 375, height: 667 }); await page.goto('/workflows/test-10-nodes'); // Toolbar should wrap await expect(page.locator('.visualizer-toolbar')).toHaveScreenshot('toolbar-mobile.png'); // Minimap should be hidden await expect(page.locator('.minimap')).not.toBeVisible(); }); test('tablet viewport renders correctly', async ({ page }) => { await page.setViewportSize({ width: 768, height: 1024 }); await page.goto('/workflows/test-10-nodes'); await expect(page.locator('.workflow-visualizer')).toHaveScreenshot('visualizer-tablet.png'); }); test('desktop viewport renders correctly', async ({ page }) => { await page.setViewportSize({ width: 1920, height: 1080 }); await page.goto('/workflows/test-10-nodes'); await expect(page.locator('.workflow-visualizer')).toHaveScreenshot('visualizer-desktop.png'); }); }); test.describe('Dark Mode', () => { test('dark mode renders correctly', async ({ page }) => { await page.goto('/workflows/test-10-nodes?theme=dark'); await expect(page.locator('.workflow-visualizer')).toHaveClass(/dark-mode/); await expect(page.locator('.dag-canvas')).toHaveScreenshot('dag-dark-mode.png'); }); test('node states in dark mode', async ({ page }) => { await page.goto('/workflows/test-all-states?theme=dark'); await expect(page.locator('.dag-canvas')).toHaveScreenshot('nodes-dark-mode.png'); }); }); test.describe('Legend', () => { test('legend displays all states', async ({ page }) => { await page.goto('/workflows/test-10-nodes'); const legend = page.locator('.legend'); await expect(legend).toBeVisible(); await expect(legend.locator('.legend-item')).toHaveCount(5); await expect(legend).toHaveScreenshot('legend.png'); }); }); test.describe('Loading and Error States', () => { test('loading overlay displays correctly', async ({ page }) => { // Intercept API to delay response await page.route('**/api/v1/workflows/*/graph', async route => { await new Promise(resolve => setTimeout(resolve, 2000)); await route.continue(); }); await page.goto('/workflows/test-10-nodes'); await expect(page.locator('.loading-overlay')).toBeVisible(); await expect(page.locator('.loading-overlay')).toHaveScreenshot('loading-state.png'); }); test('error overlay displays correctly', async ({ page }) => { // Mock API error await page.route('**/api/v1/workflows/*/graph', async route => { await route.fulfill({ status: 500, body: JSON.stringify({ error: 'Internal Server Error' }) }); }); await page.goto('/workflows/test-10-nodes'); await expect(page.locator('.error-overlay')).toBeVisible(); await expect(page.locator('.error-overlay')).toHaveScreenshot('error-state.png'); }); }); }); test.describe('Time-Travel Controls', () => { test.beforeEach(async ({ page }) => { await page.goto('/workflows/test-completed/debug'); await page.waitForSelector('.time-travel-controls'); }); test('controls render correctly', async ({ page }) => { await expect(page.locator('.time-travel-controls')).toHaveScreenshot('time-travel-controls.png'); }); test('timeline with markers', async ({ page }) => { await expect(page.locator('.timeline-container')).toHaveScreenshot('timeline-markers.png'); }); test('playhead position updates', async ({ page }) => { // Step forward await page.click('button[title*="Step Forward"]'); await page.click('button[title*="Step Forward"]'); await expect(page.locator('.timeline')).toHaveScreenshot('timeline-stepped.png'); }); }); test.describe('Step Detail Panel', () => { test.beforeEach(async ({ page }) => { await page.goto('/workflows/test-completed'); await page.locator('.node').first().click(); await page.waitForSelector('.step-detail-panel'); }); test('panel renders correctly', async ({ page }) => { await expect(page.locator('.step-detail-panel')).toHaveScreenshot('step-panel.png'); }); test('logs tab renders correctly', async ({ page }) => { await page.click('.tab:has-text("Logs")'); await expect(page.locator('.logs-tab')).toHaveScreenshot('logs-tab.png'); }); test('timing tab renders correctly', async ({ page }) => { await page.click('.tab:has-text("Timing")'); await expect(page.locator('.timing-tab')).toHaveScreenshot('timing-tab.png'); }); test('error state panel', async ({ page }) => { await page.goto('/workflows/test-failed'); await page.locator('.node-failed').first().click(); await expect(page.locator('.step-detail-panel')).toHaveScreenshot('step-panel-error.png'); }); });