项目文件夹

文件
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

691 行
30 KiB
JavaScript
可执行文件

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/usr/bin/env node
/**
* Comprehensive Mobile Test - All Pages
* Tests ALL pages in the LDR application across multiple mobile viewports
*
* Features:
* - Tests 3 mobile viewports: 360px (small Android), 375px (iPhone SE), 430px (iPhone 14)
* - Tests ALL pages including auth, settings subpages, metrics subpages
* - Checks for horizontal overflow, mobile nav, sidebar, touch targets, text readability
* - Takes screenshots of each page at each viewport
* - Generates comprehensive summary report
*/
const puppeteer = require('puppeteer');
const AuthHelper = require('../auth_helper');
const { getPuppeteerLaunchOptions } = require('../puppeteer_config');
const path = require('path');
const fs = require('fs').promises;
// Test configuration
const CONFIG = {
baseUrl: process.env.TEST_BASE_URL || 'http://127.0.0.1:5000',
timeout: 30000,
screenshotDir: path.join(__dirname, 'screenshots'),
};
// Mobile viewports to test
const VIEWPORTS = {
'Android_360': { width: 360, height: 640, isMobile: true, hasTouch: true, label: 'Small Android (360px)' },
'iPhone_SE': { width: 375, height: 667, isMobile: true, hasTouch: true, label: 'iPhone SE (375px)' },
'iPhone_14': { width: 430, height: 932, isMobile: true, hasTouch: true, label: 'iPhone 14 (430px)' },
};
// ALL pages to test
const PAGES = [
// Main pages
{ path: '/', name: 'Research', requiresAuth: true, category: 'Main' },
{ path: '/history/', name: 'History', requiresAuth: true, category: 'Main' },
{ path: '/news/', name: 'News', requiresAuth: true, category: 'Main' },
{ path: '/news/subscriptions', name: 'News-Subscriptions', requiresAuth: true, category: 'News' },
// Settings pages
{ path: '/settings/', name: 'Settings', requiresAuth: true, category: 'Settings' },
{ path: '/settings/collections', name: 'Settings-Collections', requiresAuth: true, category: 'Settings' },
{ path: '/settings/embeddings', name: 'Settings-Embeddings', requiresAuth: true, category: 'Settings' },
// Metrics pages
{ path: '/metrics/', name: 'Metrics', requiresAuth: true, category: 'Metrics' },
{ path: '/metrics/costs', name: 'Metrics-Costs', requiresAuth: true, category: 'Metrics' },
{ path: '/metrics/context-overflow', name: 'Metrics-ContextOverflow', requiresAuth: true, category: 'Metrics' },
{ path: '/metrics/star-reviews', name: 'Metrics-StarReviews', requiresAuth: true, category: 'Metrics' },
// Other pages
{ path: '/benchmark/', name: 'Benchmark', requiresAuth: true, category: 'Other' },
{ path: '/library/', name: 'Library', requiresAuth: true, category: 'Other' },
{ path: '/library/download-manager', name: 'Library-DownloadManager', requiresAuth: true, category: 'Other' },
// Auth pages
{ path: '/auth/login', name: 'Login', requiresAuth: false, category: 'Auth' },
{ path: '/auth/register', name: 'Register', requiresAuth: false, category: 'Auth' },
];
class ComprehensiveMobileTest {
constructor() {
this.results = {
startTime: new Date(),
viewportResults: {},
pageResults: {},
issues: [],
screenshots: [],
summary: {
total: 0,
passed: 0,
failed: 0,
warnings: 0,
pagesTestedPerViewport: {},
criticalIssues: [],
viewportStats: {}
}
};
}
async run() {
console.log('🚀 Comprehensive Mobile Test - All Pages');
console.log('='.repeat(70));
console.log(`Testing ${Object.keys(VIEWPORTS).length} viewports × ${PAGES.length} pages = ${Object.keys(VIEWPORTS).length * PAGES.length} tests`);
console.log('='.repeat(70));
let browser;
try {
// Ensure screenshot directory exists
await fs.mkdir(CONFIG.screenshotDir, { recursive: true });
browser = await puppeteer.launch(getPuppeteerLaunchOptions());
for (const [deviceName, viewport] of Object.entries(VIEWPORTS)) {
await this.testDevice(browser, deviceName, viewport);
}
await this.generateReport();
// Exit with error code if there are failed tests
process.exit(this.results.summary.failed > 0 ? 1 : 0);
} catch (error) {
console.error('❌ Test suite failed:', error);
process.exit(1);
} finally {
if (browser) await browser.close();
}
}
async testDevice(browser, deviceName, viewport) {
console.log(`\n${'='.repeat(70)}`);
console.log(`📱 Testing ${viewport.label}`);
console.log(`${'='.repeat(70)}`);
this.results.viewportResults[deviceName] = {
viewport: viewport.label,
tests: [],
passed: 0,
failed: 0,
warnings: 0
};
const page = await browser.newPage();
await page.setViewport(viewport);
// Authenticate for auth-required pages
const authHelper = new AuthHelper(page, CONFIG.baseUrl);
let authenticated = false;
try {
await authHelper.ensureAuthenticated();
authenticated = true;
console.log('✅ Authentication successful\n');
} catch {
console.log('⚠️ Authentication failed, testing unauthenticated pages only\n');
}
// Test each page
for (const pageInfo of PAGES) {
if (pageInfo.requiresAuth && !authenticated) {
console.log(` ⏭️ Skipping ${pageInfo.name} (requires auth)`);
continue;
}
await this.testPage(page, deviceName, viewport, pageInfo);
}
await page.close();
// Display viewport summary
const vpResult = this.results.viewportResults[deviceName];
console.log(`\n${'─'.repeat(70)}`);
console.log(`${viewport.label} Summary: ${vpResult.passed}/${vpResult.tests.length} passed, ${vpResult.failed} failed, ${vpResult.warnings} warnings`);
console.log(`${'─'.repeat(70)}`);
}
async testPage(page, deviceName, viewport, pageInfo) {
const testName = `${deviceName}/${pageInfo.name}`;
const startTime = Date.now();
try {
// Navigate to page
await page.goto(CONFIG.baseUrl + pageInfo.path, {
waitUntil: 'networkidle2',
timeout: CONFIG.timeout
});
// Wait for page to settle
await new Promise(resolve => setTimeout(resolve, 1500));
// Take screenshot
const screenshotName = `${deviceName}_${pageInfo.name}.png`;
const screenshotPath = path.join(CONFIG.screenshotDir, screenshotName);
await page.screenshot({
path: screenshotPath,
fullPage: true
});
this.results.screenshots.push({
viewport: deviceName,
page: pageInfo.name,
path: screenshotPath
});
// Run comprehensive checks
const checks = await page.evaluate(() => {
const results = {
horizontalOverflow: false,
overflowAmount: 0,
mobileNavExists: false,
mobileNavVisible: false,
sidebarHidden: true,
smallTouchTargets: 0,
touchTargetDetails: [],
textReadability: {
tooSmall: 0,
elements: []
},
viewportWidth: window.innerWidth,
scrollWidth: document.body.scrollWidth,
errors: [],
warnings: []
};
// 1. Check horizontal overflow (CRITICAL)
const threshold = 5; // Allow 5px tolerance
if (document.body.scrollWidth > window.innerWidth + threshold) {
results.horizontalOverflow = true;
results.overflowAmount = document.body.scrollWidth - window.innerWidth;
results.errors.push(`Horizontal overflow: ${results.overflowAmount}px beyond viewport`);
}
// 2. Check mobile navigation visibility
const mobileNav = document.querySelector('.ldr-mobile-bottom-nav');
results.mobileNavExists = !!mobileNav;
if (mobileNav) {
const style = window.getComputedStyle(mobileNav);
results.mobileNavVisible = style.display !== 'none' &&
mobileNav.classList.contains('visible');
}
// 3. Check sidebar hidden on mobile
const sidebar = document.querySelector('.ldr-sidebar');
if (sidebar) {
const style = window.getComputedStyle(sidebar);
results.sidebarHidden = style.display === 'none' ||
style.visibility === 'hidden' ||
parseFloat(style.opacity) === 0;
if (!results.sidebarHidden && window.innerWidth <= 768) {
results.errors.push('Desktop sidebar visible on mobile viewport');
}
}
// 4. Check touch target sizes (minimum 44x44px recommended)
const interactiveSelectors = [
'button:not([disabled])',
'a:not([disabled])',
'input[type="button"]:not([disabled])',
'input[type="submit"]:not([disabled])',
'.ldr-mobile-nav-tab',
'[role="button"]'
];
document.querySelectorAll(interactiveSelectors.join(', ')).forEach(el => {
const rect = el.getBoundingClientRect();
// Only check visible elements
if (rect.width > 0 && rect.height > 0) {
const minDimension = Math.min(rect.width, rect.height);
if (minDimension < 44) {
results.smallTouchTargets++;
if (results.touchTargetDetails.length < 5) { // Limit details to first 5
const identifier = el.id || el.className || el.tagName;
results.touchTargetDetails.push({
element: identifier.substring(0, 50),
size: `${Math.round(rect.width)}×${Math.round(rect.height)}px`
});
}
}
}
});
// 5. Check text readability (minimum 16px for body text)
const textElements = document.querySelectorAll('p, li, span, div, td, th, label');
textElements.forEach(el => {
const style = window.getComputedStyle(el);
const fontSize = parseFloat(style.fontSize);
const hasText = el.textContent.trim().length > 20; // Only check substantial text
if (hasText && fontSize < 14) {
results.textReadability.tooSmall++;
if (results.textReadability.elements.length < 3) {
results.textReadability.elements.push({
size: `${fontSize}px`,
text: el.textContent.substring(0, 30) + '...'
});
}
}
});
// Add warnings
if (results.smallTouchTargets > 5) {
results.warnings.push(`${results.smallTouchTargets} touch targets < 44px`);
}
if (results.textReadability.tooSmall > 0) {
results.warnings.push(`${results.textReadability.tooSmall} text elements < 14px`);
}
return results;
});
// Analyze results
const testResult = {
name: testName,
page: pageInfo.name,
path: pageInfo.path,
viewport: deviceName,
status: 'passed',
issues: [],
warnings: [],
checks,
duration: (Date.now() - startTime) / 1000,
screenshot: screenshotName
};
// Critical issues (fail test)
if (checks.horizontalOverflow) {
testResult.status = 'failed';
testResult.issues.push(`❌ CRITICAL: Horizontal overflow (${checks.overflowAmount}px)`);
this.results.issues.push({
severity: 'critical',
page: pageInfo.name,
viewport: deviceName,
issue: `Horizontal overflow: ${checks.overflowAmount}px`
});
}
// Check mobile nav visibility (only for authenticated pages, not auth pages)
if (pageInfo.requiresAuth && !pageInfo.path.includes('/auth/')) {
const currentUrl = page.url();
// Only require mobile nav if we're actually on the page (not redirected to login)
if (!currentUrl.includes('/auth/') && !checks.mobileNavVisible) {
testResult.status = 'failed';
testResult.issues.push('❌ CRITICAL: Mobile navigation not visible');
this.results.issues.push({
severity: 'critical',
page: pageInfo.name,
viewport: deviceName,
issue: 'Mobile navigation not visible'
});
}
}
if (!checks.sidebarHidden) {
testResult.status = 'failed';
testResult.issues.push('❌ CRITICAL: Desktop sidebar visible on mobile');
this.results.issues.push({
severity: 'critical',
page: pageInfo.name,
viewport: deviceName,
issue: 'Desktop sidebar visible'
});
}
// Warnings (don't fail test)
if (checks.smallTouchTargets > 10) {
testResult.warnings.push(`⚠️ ${checks.smallTouchTargets} small touch targets (< 44px)`);
if (checks.touchTargetDetails.length > 0) {
testResult.warnings.push(` Examples: ${checks.touchTargetDetails.map(t => `${t.element} ${t.size}`).join(', ')}`);
}
}
if (checks.textReadability.tooSmall > 5) {
testResult.warnings.push(`⚠️ ${checks.textReadability.tooSmall} text elements too small (< 14px)`);
}
// Update counters
this.results.summary.total++;
if (testResult.status === 'passed') {
this.results.summary.passed++;
this.results.viewportResults[deviceName].passed++;
} else {
this.results.summary.failed++;
this.results.viewportResults[deviceName].failed++;
}
if (testResult.warnings.length > 0) {
this.results.summary.warnings++;
this.results.viewportResults[deviceName].warnings++;
}
// Store result
this.results.viewportResults[deviceName].tests.push(testResult);
if (!this.results.pageResults[pageInfo.name]) {
this.results.pageResults[pageInfo.name] = [];
}
this.results.pageResults[pageInfo.name].push(testResult);
// Display result
const status = testResult.status === 'passed' ? '✅' : '❌';
const category = pageInfo.category ? `[${pageInfo.category}]` : '';
console.log(` ${status} ${category} ${pageInfo.name.padEnd(30)} | ${checks.viewportWidth}×${checks.scrollWidth}px`);
if (testResult.issues.length > 0) {
testResult.issues.forEach(issue => console.log(` ${issue}`));
}
if (testResult.warnings.length > 0) {
testResult.warnings.forEach(warning => console.log(` ${warning}`));
}
} catch (error) {
this.results.summary.total++;
this.results.summary.failed++;
this.results.viewportResults[deviceName].failed++;
const errorResult = {
name: testName,
page: pageInfo.name,
path: pageInfo.path,
viewport: deviceName,
status: 'failed',
issues: [`❌ ERROR: ${error.message}`],
warnings: [],
duration: (Date.now() - startTime) / 1000
};
this.results.viewportResults[deviceName].tests.push(errorResult);
console.log(` ❌ ${pageInfo.name} - ERROR: ${error.message}`);
}
}
async generateReport() {
this.results.endTime = new Date();
this.results.duration = (this.results.endTime - this.results.startTime) / 1000;
console.log('\n' + '='.repeat(70));
console.log('📊 COMPREHENSIVE TEST REPORT');
console.log('='.repeat(70));
// Overall summary
console.log('\n📈 Overall Summary:');
console.log(` Total Tests: ${this.results.summary.total}`);
console.log(` ✅ Passed: ${this.results.summary.passed}`);
console.log(` ❌ Failed: ${this.results.summary.failed}`);
console.log(` ⚠️ Warnings: ${this.results.summary.warnings}`);
console.log(` ⏱️ Duration: ${this.results.duration.toFixed(2)}s`);
console.log(` 📸 Screenshots: ${this.results.screenshots.length}`);
// Viewport breakdown
console.log('\n📱 Viewport Breakdown:');
for (const [deviceName, vpResult] of Object.entries(this.results.viewportResults)) {
const passRate = vpResult.tests.length > 0
? ((vpResult.passed / vpResult.tests.length) * 100).toFixed(1)
: 0;
console.log(` ${VIEWPORTS[deviceName].label}:`);
console.log(` ${vpResult.passed}/${vpResult.tests.length} passed (${passRate}%) | ${vpResult.failed} failed | ${vpResult.warnings} warnings`);
}
// Critical issues
if (this.results.summary.failed > 0) {
console.log('\n❌ CRITICAL ISSUES:');
const criticalIssues = this.results.issues.filter(i => i.severity === 'critical');
// Group by issue type
const issuesByType = {};
criticalIssues.forEach(issue => {
const type = issue.issue.split(':')[0];
if (!issuesByType[type]) {
issuesByType[type] = [];
}
issuesByType[type].push(issue);
});
for (const [type, issues] of Object.entries(issuesByType)) {
console.log(`\n ${type}:`);
issues.forEach(issue => {
console.log(` - ${issue.page} @ ${issue.viewport}: ${issue.issue}`);
});
}
}
// Failed pages
if (this.results.summary.failed > 0) {
console.log('\n🚨 Failed Pages:');
for (const [pageName, results] of Object.entries(this.results.pageResults)) {
const failed = results.filter(r => r.status === 'failed');
if (failed.length > 0) {
console.log(` ${pageName}:`);
failed.forEach(r => {
console.log(` - ${VIEWPORTS[r.viewport].label}: ${r.issues.join(', ')}`);
});
}
}
}
// Page category summary
console.log('\n📑 Page Category Summary:');
const categorySummary = {};
for (const page of PAGES) {
if (!categorySummary[page.category]) {
categorySummary[page.category] = { total: 0, passed: 0, failed: 0 };
}
const pageResults = this.results.pageResults[page.name] || [];
pageResults.forEach(r => {
categorySummary[page.category].total++;
if (r.status === 'passed') {
categorySummary[page.category].passed++;
} else {
categorySummary[page.category].failed++;
}
});
}
for (const [category, stats] of Object.entries(categorySummary)) {
const passRate = stats.total > 0 ? ((stats.passed / stats.total) * 100).toFixed(1) : 0;
console.log(` ${category}: ${stats.passed}/${stats.total} passed (${passRate}%)`);
}
// Screenshot info
console.log(`\n📸 Screenshots saved to: ${CONFIG.screenshotDir}/`);
console.log(` Total screenshots: ${this.results.screenshots.length}`);
// Save detailed JSON report
const reportPath = path.join(__dirname, 'mobile-test-report.json');
await fs.writeFile(reportPath, JSON.stringify(this.results, null, 2));
console.log(`\n💾 Detailed report saved to: ${reportPath}`);
// Generate HTML report
await this.generateHtmlReport();
console.log('\n' + '='.repeat(70));
if (this.results.summary.failed === 0) {
console.log('🎉 ALL TESTS PASSED!');
} else {
console.log(`⚠️ ${this.results.summary.failed} TEST(S) FAILED`);
}
console.log('='.repeat(70) + '\n');
}
async generateHtmlReport() {
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mobile Test Report - ${new Date().toLocaleDateString()}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 20px; background: #f5f5f5; }
.container { max-width: 1400px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
h1 { color: #333; margin-bottom: 20px; }
h2 { color: #555; margin: 30px 0 15px; padding-bottom: 10px; border-bottom: 2px solid #eee; }
h3 { color: #666; margin: 20px 0 10px; }
.summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin: 20px 0; }
.stat-card { padding: 20px; border-radius: 6px; background: #f8f9fa; }
.stat-card.passed { border-left: 4px solid #28a745; }
.stat-card.failed { border-left: 4px solid #dc3545; }
.stat-card.warning { border-left: 4px solid #ffc107; }
.stat-label { font-size: 14px; color: #666; margin-bottom: 5px; }
.stat-value { font-size: 32px; font-weight: bold; color: #333; }
.viewport-results { margin: 20px 0; }
.viewport { margin-bottom: 30px; padding: 20px; background: #f8f9fa; border-radius: 6px; }
.test-grid { display: grid; gap: 15px; margin-top: 15px; }
.test-item { padding: 15px; background: white; border-radius: 4px; border-left: 4px solid #28a745; }
.test-item.failed { border-left-color: #dc3545; }
.test-item.warning { border-left-color: #ffc107; }
.test-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.test-name { font-weight: 600; color: #333; }
.test-status { padding: 4px 12px; border-radius: 4px; font-size: 12px; font-weight: 600; }
.test-status.passed { background: #d4edda; color: #155724; }
.test-status.failed { background: #f8d7da; color: #721c24; }
.test-details { font-size: 14px; color: #666; margin-top: 8px; }
.issue { color: #dc3545; margin-top: 5px; }
.warning { color: #ffc107; margin-top: 5px; }
.screenshot-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; margin: 20px 0; }
.screenshot { border: 1px solid #ddd; border-radius: 4px; padding: 10px; background: white; }
.screenshot img { width: 100%; border-radius: 4px; }
.screenshot-label { text-align: center; margin-top: 8px; font-size: 12px; color: #666; }
.category-summary { display: grid; gap: 10px; }
.category-item { padding: 12px; background: #f8f9fa; border-radius: 4px; display: flex; justify-content: space-between; align-items: center; }
.progress-bar { width: 200px; height: 20px; background: #e9ecef; border-radius: 10px; overflow: hidden; }
.progress-fill { height: 100%; background: #28a745; transition: width 0.3s; }
</style>
</head>
<body>
<div class="container">
<h1>📱 Mobile Test Report</h1>
<p style="color: #666; margin-bottom: 20px;">Generated on ${new Date().toLocaleString()}</p>
<h2>📈 Overall Summary</h2>
<div class="summary">
<div class="stat-card">
<div class="stat-label">Total Tests</div>
<div class="stat-value">${this.results.summary.total}</div>
</div>
<div class="stat-card passed">
<div class="stat-label">Passed</div>
<div class="stat-value">${this.results.summary.passed}</div>
</div>
<div class="stat-card failed">
<div class="stat-label">Failed</div>
<div class="stat-value">${this.results.summary.failed}</div>
</div>
<div class="stat-card warning">
<div class="stat-label">Warnings</div>
<div class="stat-value">${this.results.summary.warnings}</div>
</div>
<div class="stat-card">
<div class="stat-label">Duration</div>
<div class="stat-value">${this.results.duration.toFixed(1)}s</div>
</div>
<div class="stat-card">
<div class="stat-label">Screenshots</div>
<div class="stat-value">${this.results.screenshots.length}</div>
</div>
</div>
<h2>📱 Viewport Results</h2>
<div class="viewport-results">
${Object.entries(this.results.viewportResults).map(([deviceName, vpResult]) => `
<div class="viewport">
<h3>${VIEWPORTS[deviceName].label} (${VIEWPORTS[deviceName].width}px)</h3>
<p style="margin: 10px 0;">
<strong>${vpResult.passed}/${vpResult.tests.length}</strong> tests passed
(${vpResult.tests.length > 0 ? ((vpResult.passed / vpResult.tests.length) * 100).toFixed(1) : 0}%)
</p>
<div class="test-grid">
${vpResult.tests.map(test => `
<div class="test-item ${test.status} ${test.warnings.length > 0 ? 'warning' : ''}">
<div class="test-header">
<span class="test-name">${test.page}</span>
<span class="test-status ${test.status}">${test.status.toUpperCase()}</span>
</div>
<div class="test-details">
<div>Path: ${test.path}</div>
<div>Duration: ${test.duration.toFixed(2)}s</div>
${test.screenshot ? `<div>Screenshot: ${test.screenshot}</div>` : ''}
${test.issues.length > 0 ? test.issues.map(issue => `<div class="issue">${issue}</div>`).join('') : ''}
${test.warnings.length > 0 ? test.warnings.map(warning => `<div class="warning">${warning}</div>`).join('') : ''}
</div>
</div>
`).join('')}
</div>
</div>
`).join('')}
</div>
<h2>📑 Page Category Summary</h2>
<div class="category-summary">
${(() => {
const categorySummary = {};
for (const page of PAGES) {
if (!categorySummary[page.category]) {
categorySummary[page.category] = { total: 0, passed: 0, failed: 0 };
}
const pageResults = this.results.pageResults[page.name] || [];
pageResults.forEach(r => {
categorySummary[page.category].total++;
if (r.status === 'passed') {
categorySummary[page.category].passed++;
} else {
categorySummary[page.category].failed++;
}
});
}
return Object.entries(categorySummary).map(([category, stats]) => {
const passRate = stats.total > 0 ? ((stats.passed / stats.total) * 100) : 0;
return `
<div class="category-item">
<div>
<strong>${category}</strong>
<div style="font-size: 14px; color: #666;">${stats.passed}/${stats.total} passed</div>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: ${passRate}%"></div>
</div>
</div>
`;
}).join('');
})()}
</div>
${this.results.summary.failed > 0 ? `
<h2>❌ Critical Issues</h2>
<div style="background: #f8d7da; padding: 15px; border-radius: 6px; border-left: 4px solid #dc3545;">
${this.results.issues.map(issue => `
<div style="margin: 8px 0;">
<strong>${issue.page}</strong> @ ${issue.viewport}: ${issue.issue}
</div>
`).join('')}
</div>
` : ''}
<h2>📸 Screenshots</h2>
<p style="color: #666; margin-bottom: 15px;">All screenshots saved to: ${CONFIG.screenshotDir}/</p>
<p style="color: #666;">Total screenshots: ${this.results.screenshots.length}</p>
</div>
</body>
</html>`;
const htmlPath = path.join(__dirname, 'mobile-test-report.html');
await fs.writeFile(htmlPath, html);
console.log(`📄 HTML report saved to: ${htmlPath}`);
}
}
// Run
const test = new ComprehensiveMobileTest();
test.run();