/** * Screenshot Manager for HVAC Testing Framework * * Provides comprehensive screenshot functionality: * - Organized screenshot storage with naming conventions * - Failure capture with context information * - Full page and element-specific screenshots * - Screenshot comparison utilities * * @package HVAC_Community_Events * @version 2.0.0 * @created 2025-08-27 */ const fs = require('fs').promises; const path = require('path'); const ConfigManager = require('../core/ConfigManager'); class ScreenshotManager { constructor() { this.config = ConfigManager; this.baseDir = this.config.get('media.screenshotDir', './test-results/screenshots'); this.quality = this.config.get('media.quality', 80); this.fullPage = this.config.get('media.fullPage', false); this.ensureDirectoryExists(); } /** * Ensure screenshots directory exists */ async ensureDirectoryExists() { try { await fs.mkdir(this.baseDir, { recursive: true }); } catch (error) { console.warn('Could not create screenshots directory:', error.message); } } /** * Capture screenshot with context and naming */ async capture(page, name, basePath = null, options = {}) { try { const screenshotPath = await this.generatePath(name, basePath, options); const screenshotOptions = { path: screenshotPath, quality: options.quality || this.quality, fullPage: options.fullPage !== undefined ? options.fullPage : this.fullPage, clip: options.clip, timeout: options.timeout || 5000, ...options }; // Remove custom options that aren't part of Playwright's screenshot API delete screenshotOptions.category; delete screenshotOptions.testId; delete screenshotOptions.metadata; await page.screenshot(screenshotOptions); console.log(`๐Ÿ“ธ Screenshot captured: ${screenshotPath}`); // Save metadata if provided if (options.metadata) { await this.saveMetadata(screenshotPath, options.metadata); } return screenshotPath; } catch (error) { console.error('โŒ Screenshot capture failed:', error.message); throw error; } } /** * Capture failure screenshot with additional context */ async captureFailure(page, testInfo, error, options = {}) { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const testName = this.sanitizeFileName(testInfo.title); const name = `failure-${testName}-${timestamp}`; const metadata = { testTitle: testInfo.title, testFile: testInfo.file, error: error.message, url: page.url(), timestamp: new Date().toISOString(), viewport: await page.viewportSize(), userAgent: await page.evaluate(() => navigator.userAgent), ...options.metadata }; try { const screenshotPath = await this.capture(page, name, null, { fullPage: true, metadata, category: 'failures', ...options }); // Also capture console logs await this.captureConsoleLogs(page, screenshotPath, testInfo); // Capture page source await this.capturePageSource(page, screenshotPath); return screenshotPath; } catch (captureError) { console.error('Failed to capture failure screenshot:', captureError.message); return null; } } /** * Capture element-specific screenshot */ async captureElement(page, selector, name, options = {}) { try { const element = await page.locator(selector); await element.waitFor({ state: 'visible', timeout: 5000 }); const boundingBox = await element.boundingBox(); if (!boundingBox) { throw new Error(`Element not visible or has no bounding box: ${selector}`); } return await this.capture(page, name, null, { clip: boundingBox, ...options }); } catch (error) { console.error(`Failed to capture element screenshot (${selector}):`, error.message); throw error; } } /** * Capture comparison screenshots (before/after) */ async captureComparison(page, name, action, options = {}) { const beforePath = await this.capture(page, `${name}-before`, null, options); // Perform the action if (typeof action === 'function') { await action(); } const afterPath = await this.capture(page, `${name}-after`, null, options); return { before: beforePath, after: afterPath }; } /** * Capture mobile/desktop responsive comparison */ async captureResponsive(page, name, options = {}) { const originalViewport = page.viewportSize(); // Desktop screenshot const desktopViewport = this.config.get('browser.viewport'); await page.setViewportSize(desktopViewport); const desktopPath = await this.capture(page, `${name}-desktop`, null, options); // Mobile screenshot const mobileViewport = this.config.get('browser.mobileViewport'); await page.setViewportSize(mobileViewport); const mobilePath = await this.capture(page, `${name}-mobile`, null, options); // Restore original viewport if (originalViewport) { await page.setViewportSize(originalViewport); } return { desktop: desktopPath, mobile: mobilePath }; } /** * Generate screenshot file path */ async generatePath(name, basePath = null, options = {}) { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const sanitizedName = this.sanitizeFileName(name); let directory = basePath || this.baseDir; // Create category subdirectory if specified if (options.category) { directory = path.join(directory, options.category); await fs.mkdir(directory, { recursive: true }); } // Create test-specific subdirectory if testId provided if (options.testId) { directory = path.join(directory, this.sanitizeFileName(options.testId)); await fs.mkdir(directory, { recursive: true }); } const filename = `${sanitizedName}-${timestamp}.png`; return path.join(directory, filename); } /** * Save screenshot metadata */ async saveMetadata(screenshotPath, metadata) { try { const metadataPath = screenshotPath.replace('.png', '.json'); await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2)); } catch (error) { console.warn('Could not save screenshot metadata:', error.message); } } /** * Capture console logs alongside screenshot */ async captureConsoleLogs(page, screenshotPath, testInfo) { try { const logs = []; // Get console messages from page context page.on('console', msg => { logs.push({ type: msg.type(), text: msg.text(), location: msg.location(), timestamp: new Date().toISOString() }); }); // Save console logs const logsPath = screenshotPath.replace('.png', '-console.json'); const logData = { testTitle: testInfo.title, url: page.url(), timestamp: new Date().toISOString(), logs }; await fs.writeFile(logsPath, JSON.stringify(logData, null, 2)); } catch (error) { console.warn('Could not capture console logs:', error.message); } } /** * Capture page source alongside screenshot */ async capturePageSource(page, screenshotPath) { try { const pageSource = await page.content(); const sourcePath = screenshotPath.replace('.png', '-source.html'); await fs.writeFile(sourcePath, pageSource); } catch (error) { console.warn('Could not capture page source:', error.message); } } /** * Sanitize filename for cross-platform compatibility */ sanitizeFileName(name) { return name .replace(/[^a-zA-Z0-9\-_.]/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .toLowerCase(); } /** * Clean up old screenshots (keep last N days) */ async cleanup(daysToKeep = 7) { try { const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); const files = await this.getScreenshotFiles(this.baseDir); let deletedCount = 0; for (const file of files) { const stats = await fs.stat(file); if (stats.mtime < cutoffDate) { await fs.unlink(file); deletedCount++; } } console.log(`๐Ÿงน Cleaned up ${deletedCount} old screenshots`); } catch (error) { console.warn('Could not clean up screenshots:', error.message); } } /** * Get all screenshot files recursively */ async getScreenshotFiles(directory) { const files = []; try { const entries = await fs.readdir(directory, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(directory, entry.name); if (entry.isDirectory()) { const subFiles = await this.getScreenshotFiles(fullPath); files.push(...subFiles); } else if (entry.name.endsWith('.png')) { files.push(fullPath); } } } catch (error) { // Directory doesn't exist or can't be read } return files; } /** * Generate screenshot summary report */ async generateReport() { try { const files = await this.getScreenshotFiles(this.baseDir); const report = { totalScreenshots: files.length, categories: {}, generatedAt: new Date().toISOString() }; // Group by category (subdirectory) for (const file of files) { const relativePath = path.relative(this.baseDir, file); const category = path.dirname(relativePath); if (!report.categories[category]) { report.categories[category] = 0; } report.categories[category]++; } const reportPath = path.join(this.baseDir, 'screenshot-report.json'); await fs.writeFile(reportPath, JSON.stringify(report, null, 2)); console.log(`๐Ÿ“Š Screenshot report generated: ${reportPath}`); return report; } catch (error) { console.warn('Could not generate screenshot report:', error.message); return null; } } /** * Create screenshot gallery HTML */ async createGallery(title = 'Test Screenshots') { try { const files = await this.getScreenshotFiles(this.baseDir); const galleryData = []; for (const file of files) { const relativePath = path.relative(this.baseDir, file); const stats = await fs.stat(file); // Try to load metadata const metadataPath = file.replace('.png', '.json'); let metadata = null; try { const metadataContent = await fs.readFile(metadataPath, 'utf8'); metadata = JSON.parse(metadataContent); } catch (error) { // No metadata file } galleryData.push({ path: relativePath, filename: path.basename(file), size: stats.size, created: stats.birthtime, modified: stats.mtime, metadata }); } // Sort by creation date (newest first) galleryData.sort((a, b) => b.created - a.created); // Generate HTML const html = this.generateGalleryHTML(title, galleryData); const galleryPath = path.join(this.baseDir, 'gallery.html'); await fs.writeFile(galleryPath, html); console.log(`๐Ÿ–ผ๏ธ Screenshot gallery created: ${galleryPath}`); return galleryPath; } catch (error) { console.warn('Could not create screenshot gallery:', error.message); return null; } } /** * Generate HTML for screenshot gallery */ generateGalleryHTML(title, screenshots) { const screenshotCards = screenshots.map(screenshot => `
${screenshot.filename}

${screenshot.filename}

Created: ${screenshot.created.toLocaleString()}

Size: ${(screenshot.size / 1024).toFixed(1)} KB

${screenshot.metadata ? `
Metadata
${JSON.stringify(screenshot.metadata, null, 2)}
` : ''}
`).join(''); return ` ${title}

${title}

Total Screenshots: ${screenshots.length}

Generated: ${new Date().toLocaleString()}

`; } } module.exports = ScreenshotManager;