Some checks are pending
HVAC Plugin CI/CD Pipeline / Security Analysis (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Code Quality & Standards (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Unit Tests (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Integration Tests (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Deploy to Staging (push) Blocked by required conditions
HVAC Plugin CI/CD Pipeline / Deploy to Production (push) Blocked by required conditions
HVAC Plugin CI/CD Pipeline / Notification (push) Blocked by required conditions
Security Monitoring & Compliance / Dependency Vulnerability Scan (push) Waiting to run
Security Monitoring & Compliance / Secrets & Credential Scan (push) Waiting to run
Security Monitoring & Compliance / WordPress Security Analysis (push) Waiting to run
Security Monitoring & Compliance / Static Code Security Analysis (push) Waiting to run
Security Monitoring & Compliance / Security Compliance Validation (push) Waiting to run
Security Monitoring & Compliance / Security Summary Report (push) Blocked by required conditions
Security Monitoring & Compliance / Security Team Notification (push) Blocked by required conditions
- Add 90+ test files including E2E, unit, and integration tests - Implement Page Object Model (POM) architecture - Add Docker testing environment with comprehensive services - Include modernized test framework with error recovery - Add specialized test suites for master trainer and trainer workflows - Update .gitignore to properly track test infrastructure 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
469 lines
No EOL
16 KiB
JavaScript
469 lines
No EOL
16 KiB
JavaScript
/**
|
|
* 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 => `
|
|
<div class="screenshot-card">
|
|
<img src="${screenshot.path}" alt="${screenshot.filename}" loading="lazy">
|
|
<div class="screenshot-info">
|
|
<h4>${screenshot.filename}</h4>
|
|
<p><strong>Created:</strong> ${screenshot.created.toLocaleString()}</p>
|
|
<p><strong>Size:</strong> ${(screenshot.size / 1024).toFixed(1)} KB</p>
|
|
${screenshot.metadata ? `
|
|
<details>
|
|
<summary>Metadata</summary>
|
|
<pre>${JSON.stringify(screenshot.metadata, null, 2)}</pre>
|
|
</details>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
return `
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>${title}</title>
|
|
<style>
|
|
body { font-family: system-ui, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
|
|
h1 { text-align: center; color: #333; }
|
|
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }
|
|
.screenshot-card { background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
|
.screenshot-card img { width: 100%; height: 200px; object-fit: cover; }
|
|
.screenshot-info { padding: 15px; }
|
|
.screenshot-info h4 { margin: 0 0 10px 0; color: #333; }
|
|
.screenshot-info p { margin: 5px 0; color: #666; font-size: 14px; }
|
|
details { margin-top: 10px; }
|
|
pre { background: #f8f8f8; padding: 10px; border-radius: 4px; font-size: 12px; overflow-x: auto; }
|
|
.stats { text-align: center; margin-bottom: 30px; padding: 20px; background: white; border-radius: 8px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>${title}</h1>
|
|
<div class="stats">
|
|
<p><strong>Total Screenshots:</strong> ${screenshots.length}</p>
|
|
<p><strong>Generated:</strong> ${new Date().toLocaleString()}</p>
|
|
</div>
|
|
<div class="gallery">
|
|
${screenshotCards}
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
}
|
|
}
|
|
|
|
module.exports = ScreenshotManager; |