#!/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 = `
Generated on ${new Date().toLocaleString()}
All screenshots saved to: ${CONFIG.screenshotDir}/
Total screenshots: ${this.results.screenshots.length}