|
| 1 | +import { test, expect, Page } from '@playwright/test'; |
| 2 | +import * as fs from 'fs'; |
| 3 | +import * as path from 'path'; |
| 4 | + |
| 5 | +/** |
| 6 | + * Comprehensive Visual Regression Test Suite |
| 7 | + * |
| 8 | + * Captures screenshots of every screen at multiple resolutions for: |
| 9 | + * - Visual regression testing |
| 10 | + * - DevOps monitoring |
| 11 | + * - UI/UX documentation |
| 12 | + * - Cross-device compatibility verification |
| 13 | + */ |
| 14 | + |
| 15 | +// Device configurations with real-world resolutions |
| 16 | +const DEVICES = [ |
| 17 | + // Mobile Phones - Portrait |
| 18 | + { name: 'iPhone-SE', width: 375, height: 667, deviceScaleFactor: 2 }, |
| 19 | + { name: 'iPhone-12-13-14', width: 390, height: 844, deviceScaleFactor: 3 }, |
| 20 | + { name: 'iPhone-14-Pro-Max', width: 430, height: 932, deviceScaleFactor: 3 }, |
| 21 | + { name: 'Samsung-Galaxy-S21', width: 360, height: 800, deviceScaleFactor: 3 }, |
| 22 | + { name: 'Google-Pixel-7', width: 412, height: 915, deviceScaleFactor: 2.625 }, |
| 23 | + |
| 24 | + // Mobile Phones - Landscape |
| 25 | + { name: 'iPhone-14-Landscape', width: 844, height: 390, deviceScaleFactor: 3 }, |
| 26 | + { name: 'Samsung-Galaxy-Landscape', width: 800, height: 360, deviceScaleFactor: 3 }, |
| 27 | + |
| 28 | + // Tablets - Portrait |
| 29 | + { name: 'iPad-Mini', width: 768, height: 1024, deviceScaleFactor: 2 }, |
| 30 | + { name: 'iPad-Air', width: 820, height: 1180, deviceScaleFactor: 2 }, |
| 31 | + { name: 'iPad-Pro-11', width: 834, height: 1194, deviceScaleFactor: 2 }, |
| 32 | + { name: 'iPad-Pro-12.9', width: 1024, height: 1366, deviceScaleFactor: 2 }, |
| 33 | + { name: 'Samsung-Galaxy-Tab', width: 800, height: 1280, deviceScaleFactor: 2 }, |
| 34 | + |
| 35 | + // Tablets - Landscape |
| 36 | + { name: 'iPad-Pro-11-Landscape', width: 1194, height: 834, deviceScaleFactor: 2 }, |
| 37 | + { name: 'iPad-Pro-12.9-Landscape', width: 1366, height: 1024, deviceScaleFactor: 2 }, |
| 38 | + |
| 39 | + // Desktop - Common resolutions |
| 40 | + { name: 'Desktop-HD', width: 1366, height: 768, deviceScaleFactor: 1 }, |
| 41 | + { name: 'Desktop-Full-HD', width: 1920, height: 1080, deviceScaleFactor: 1 }, |
| 42 | + { name: 'Desktop-QHD', width: 2560, height: 1440, deviceScaleFactor: 1 }, |
| 43 | + { name: 'Desktop-4K', width: 3840, height: 2160, deviceScaleFactor: 1 }, |
| 44 | + |
| 45 | + // Ultrawide |
| 46 | + { name: 'Ultrawide-QHD', width: 3440, height: 1440, deviceScaleFactor: 1 }, |
| 47 | + { name: 'Ultrawide-4K', width: 5120, height: 2160, deviceScaleFactor: 1 }, |
| 48 | +]; |
| 49 | + |
| 50 | +// All screens/routes to capture |
| 51 | +const SCREENS = [ |
| 52 | + { route: '/', name: 'landing-page' }, |
| 53 | + { route: '/login', name: 'login' }, |
| 54 | + { route: '/workspace', name: 'workspace' }, |
| 55 | + { route: '/graph', name: 'graph-view' }, |
| 56 | + { route: '/projects', name: 'projects' }, |
| 57 | + { route: '/settings', name: 'settings' }, |
| 58 | + { route: '/profile', name: 'profile' }, |
| 59 | + { route: '/admin', name: 'admin-panel' }, |
| 60 | + { route: '/admin/users', name: 'admin-users' }, |
| 61 | + { route: '/admin/system', name: 'admin-system' }, |
| 62 | +]; |
| 63 | + |
| 64 | +// Create timestamped directory for this test run |
| 65 | +const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); |
| 66 | +const SCREENSHOT_BASE_DIR = `test-artifacts/visual-regression/${timestamp}`; |
| 67 | + |
| 68 | +// Ensure screenshot directories exist |
| 69 | +function ensureDirectoryExists(dir: string) { |
| 70 | + if (!fs.existsSync(dir)) { |
| 71 | + fs.mkdirSync(dir, { recursive: true }); |
| 72 | + } |
| 73 | +} |
| 74 | + |
| 75 | +// Helper to take a screenshot with retry logic |
| 76 | +async function captureScreenshot( |
| 77 | + page: Page, |
| 78 | + filepath: string, |
| 79 | + options: { fullPage?: boolean; timeout?: number } = {} |
| 80 | +) { |
| 81 | + const maxRetries = 3; |
| 82 | + let lastError: Error | null = null; |
| 83 | + |
| 84 | + for (let i = 0; i < maxRetries; i++) { |
| 85 | + try { |
| 86 | + await page.screenshot({ |
| 87 | + path: filepath, |
| 88 | + fullPage: options.fullPage ?? true, |
| 89 | + timeout: options.timeout ?? 30000, |
| 90 | + }); |
| 91 | + return true; |
| 92 | + } catch (error) { |
| 93 | + lastError = error as Error; |
| 94 | + console.warn(`Screenshot attempt ${i + 1} failed: ${filepath}`, error); |
| 95 | + await page.waitForTimeout(1000); |
| 96 | + } |
| 97 | + } |
| 98 | + |
| 99 | + console.error(`Failed to capture screenshot after ${maxRetries} attempts: ${filepath}`, lastError); |
| 100 | + return false; |
| 101 | +} |
| 102 | + |
| 103 | +// Main test suite |
| 104 | +test.describe('Visual Regression Test Suite - All Screens, All Resolutions', () => { |
| 105 | + |
| 106 | + test.beforeAll(() => { |
| 107 | + // Create base directory structure |
| 108 | + ensureDirectoryExists(SCREENSHOT_BASE_DIR); |
| 109 | + |
| 110 | + // Create device-specific directories |
| 111 | + DEVICES.forEach(device => { |
| 112 | + ensureDirectoryExists(path.join(SCREENSHOT_BASE_DIR, device.name)); |
| 113 | + }); |
| 114 | + |
| 115 | + console.log(`\n📸 Visual Regression Suite Started`); |
| 116 | + console.log(`📁 Screenshots will be saved to: ${SCREENSHOT_BASE_DIR}`); |
| 117 | + console.log(`📱 Testing ${DEVICES.length} device configurations`); |
| 118 | + console.log(`🖼️ Capturing ${SCREENS.length} screens per device`); |
| 119 | + console.log(`📊 Total screenshots: ${DEVICES.length * SCREENS.length}\n`); |
| 120 | + }); |
| 121 | + |
| 122 | + // Generate a test for each device configuration |
| 123 | + for (const device of DEVICES) { |
| 124 | + test.describe(`Device: ${device.name} (${device.width}x${device.height})`, () => { |
| 125 | + |
| 126 | + // Test each screen at this resolution |
| 127 | + for (const screen of SCREENS) { |
| 128 | + test(`Capture ${screen.name} at ${device.name}`, async ({ page }) => { |
| 129 | + // Set viewport for this device |
| 130 | + await page.setViewportSize({ |
| 131 | + width: device.width, |
| 132 | + height: device.height, |
| 133 | + }); |
| 134 | + |
| 135 | + // Navigate to the screen |
| 136 | + const url = `http://localhost:3127${screen.route}`; |
| 137 | + |
| 138 | + try { |
| 139 | + await page.goto(url, { |
| 140 | + waitUntil: 'networkidle', |
| 141 | + timeout: 30000 |
| 142 | + }); |
| 143 | + } catch (error) { |
| 144 | + console.warn(`Failed to navigate to ${url}, continuing with screenshot...`); |
| 145 | + } |
| 146 | + |
| 147 | + // Wait for page to settle |
| 148 | + await page.waitForTimeout(2000); |
| 149 | + |
| 150 | + // Additional wait for any animations or dynamic content |
| 151 | + try { |
| 152 | + // Wait for main content area if it exists |
| 153 | + await page.waitForSelector('main, [role="main"], .main-content', { |
| 154 | + timeout: 5000 |
| 155 | + }).catch(() => { |
| 156 | + // Ignore if selector not found |
| 157 | + }); |
| 158 | + } catch { |
| 159 | + // Continue even if selector not found |
| 160 | + } |
| 161 | + |
| 162 | + // Construct filename |
| 163 | + const sanitizedScreenName = screen.name.replace(/[^a-z0-9-]/gi, '_'); |
| 164 | + const filename = `${sanitizedScreenName}.png`; |
| 165 | + const filepath = path.join(SCREENSHOT_BASE_DIR, device.name, filename); |
| 166 | + |
| 167 | + // Capture screenshot |
| 168 | + const success = await captureScreenshot(page, filepath); |
| 169 | + |
| 170 | + // Log result |
| 171 | + if (success) { |
| 172 | + console.log(`✅ ${device.name}/${filename}`); |
| 173 | + } else { |
| 174 | + console.error(`❌ ${device.name}/${filename}`); |
| 175 | + } |
| 176 | + |
| 177 | + // Soft assertion - don't fail test if screenshot fails |
| 178 | + // This allows the suite to continue even if some screens are inaccessible |
| 179 | + expect(success).toBeTruthy(); |
| 180 | + }); |
| 181 | + } |
| 182 | + |
| 183 | + // Additional test: Capture interactive states |
| 184 | + test(`Interactive states at ${device.name}`, async ({ page }) => { |
| 185 | + await page.setViewportSize({ |
| 186 | + width: device.width, |
| 187 | + height: device.height, |
| 188 | + }); |
| 189 | + |
| 190 | + // Go to main page |
| 191 | + await page.goto('http://localhost:3127', { |
| 192 | + waitUntil: 'networkidle', |
| 193 | + timeout: 30000 |
| 194 | + }).catch(() => {}); |
| 195 | + |
| 196 | + await page.waitForTimeout(2000); |
| 197 | + |
| 198 | + const deviceDir = path.join(SCREENSHOT_BASE_DIR, device.name); |
| 199 | + |
| 200 | + // Capture hover states on buttons if they exist |
| 201 | + const buttons = await page.locator('button').all(); |
| 202 | + for (let i = 0; i < Math.min(buttons.length, 5); i++) { |
| 203 | + try { |
| 204 | + await buttons[i].hover(); |
| 205 | + await page.waitForTimeout(500); |
| 206 | + await captureScreenshot( |
| 207 | + page, |
| 208 | + path.join(deviceDir, `interactive-button-hover-${i}.png`), |
| 209 | + { fullPage: false } |
| 210 | + ); |
| 211 | + } catch { |
| 212 | + // Continue if button interaction fails |
| 213 | + } |
| 214 | + } |
| 215 | + |
| 216 | + // Capture modal states if modals exist |
| 217 | + const modalTriggers = await page.locator('[data-testid*="modal"], [aria-haspopup="dialog"]').all(); |
| 218 | + for (let i = 0; i < Math.min(modalTriggers.length, 3); i++) { |
| 219 | + try { |
| 220 | + await modalTriggers[i].click(); |
| 221 | + await page.waitForTimeout(1000); |
| 222 | + await captureScreenshot( |
| 223 | + page, |
| 224 | + path.join(deviceDir, `modal-state-${i}.png`) |
| 225 | + ); |
| 226 | + |
| 227 | + // Try to close modal |
| 228 | + await page.keyboard.press('Escape'); |
| 229 | + await page.waitForTimeout(500); |
| 230 | + } catch { |
| 231 | + // Continue if modal interaction fails |
| 232 | + } |
| 233 | + } |
| 234 | + }); |
| 235 | + }); |
| 236 | + } |
| 237 | + |
| 238 | + test.afterAll(async () => { |
| 239 | + // Generate summary report |
| 240 | + const summaryPath = path.join(SCREENSHOT_BASE_DIR, 'SUMMARY.md'); |
| 241 | + |
| 242 | + let summary = `# Visual Regression Test Summary\n\n`; |
| 243 | + summary += `**Test Run:** ${timestamp}\n`; |
| 244 | + summary += `**Total Devices:** ${DEVICES.length}\n`; |
| 245 | + summary += `**Total Screens:** ${SCREENS.length}\n`; |
| 246 | + summary += `**Total Screenshots:** ${DEVICES.length * SCREENS.length}\n\n`; |
| 247 | + |
| 248 | + summary += `## Device Configurations\n\n`; |
| 249 | + summary += `| Device | Resolution | Scale Factor | Orientation |\n`; |
| 250 | + summary += `|--------|-----------|--------------|-------------|\n`; |
| 251 | + |
| 252 | + DEVICES.forEach(device => { |
| 253 | + const orientation = device.width > device.height ? 'Landscape' : 'Portrait'; |
| 254 | + summary += `| ${device.name} | ${device.width}x${device.height} | ${device.deviceScaleFactor}x | ${orientation} |\n`; |
| 255 | + }); |
| 256 | + |
| 257 | + summary += `\n## Screens Captured\n\n`; |
| 258 | + SCREENS.forEach(screen => { |
| 259 | + summary += `- **${screen.name}**: \`${screen.route}\`\n`; |
| 260 | + }); |
| 261 | + |
| 262 | + summary += `\n## Directory Structure\n\n`; |
| 263 | + summary += `\`\`\`\n`; |
| 264 | + summary += `${SCREENSHOT_BASE_DIR}/\n`; |
| 265 | + DEVICES.forEach(device => { |
| 266 | + summary += `├── ${device.name}/\n`; |
| 267 | + SCREENS.forEach(screen => { |
| 268 | + summary += `│ ├── ${screen.name.replace(/[^a-z0-9-]/gi, '_')}.png\n`; |
| 269 | + }); |
| 270 | + }); |
| 271 | + summary += `\`\`\`\n`; |
| 272 | + |
| 273 | + summary += `\n## Usage\n\n`; |
| 274 | + summary += `These screenshots can be used for:\n`; |
| 275 | + summary += `- Visual regression testing (compare against baseline)\n`; |
| 276 | + summary += `- UI/UX documentation\n`; |
| 277 | + summary += `- Cross-device compatibility verification\n`; |
| 278 | + summary += `- Design review and QA\n`; |
| 279 | + summary += `- DevOps monitoring and alerts\n\n`; |
| 280 | + |
| 281 | + summary += `## Integration with GraphDone-DevOps\n\n`; |
| 282 | + summary += `To integrate these screenshots with your DevOps pipeline:\n\n`; |
| 283 | + summary += `1. **Automated comparison**: Use tools like Pixelmatch or Percy for visual diff\n`; |
| 284 | + summary += `2. **Artifact storage**: Upload to S3/artifact storage for historical tracking\n`; |
| 285 | + summary += `3. **CI/CD alerts**: Trigger notifications on visual changes\n`; |
| 286 | + summary += `4. **Baseline management**: Store approved screenshots as baselines\n\n`; |
| 287 | + |
| 288 | + fs.writeFileSync(summaryPath, summary); |
| 289 | + |
| 290 | + console.log(`\n✅ Visual Regression Suite Complete!`); |
| 291 | + console.log(`📁 Screenshots saved to: ${SCREENSHOT_BASE_DIR}`); |
| 292 | + console.log(`📄 Summary report: ${summaryPath}\n`); |
| 293 | + }); |
| 294 | +}); |
0 commit comments