#!/usr/bin/env node /** * Mobile Navigation CI/CD Test Suite * Programmatic tests with assertions for automated testing */ const puppeteer = require('puppeteer'); const path = require('path'); const fs = require('fs').promises; const { getPuppeteerLaunchOptions } = require('../puppeteer_config'); // Test configuration const TEST_CONFIG = { baseUrl: process.env.TEST_BASE_URL || 'http://127.0.0.1:5000', headless: process.env.HEADLESS !== 'false', timeout: 30000, screenshotOnFailure: process.env.CI ? false : (process.env.SCREENSHOT_ON_FAILURE !== 'false'), // Disable screenshots in CI outputFormat: process.env.OUTPUT_FORMAT || 'json', // json, junit, console }; // Test devices const DEVICES = { 'iPhone_14_Pro_Max': { width: 430, height: 932, deviceScaleFactor: 3, isMobile: true, hasTouch: true, }, 'iPhone_SE': { width: 375, height: 667, deviceScaleFactor: 2, isMobile: true, hasTouch: true }, 'iPad_Mini': { width: 768, height: 1024, deviceScaleFactor: 2, isMobile: true, hasTouch: true }, 'Desktop': { width: 1920, height: 1080, deviceScaleFactor: 1, isMobile: false, hasTouch: false } }; // Test results storage class TestResults { constructor() { this.startTime = new Date(); this.tests = []; this.summary = { total: 0, passed: 0, failed: 0, skipped: 0 }; } addTest(test) { this.tests.push(test); this.summary.total++; this.summary[test.status]++; } getJUnit() { const duration = (new Date() - this.startTime) / 1000; let xml = `\n`; xml += `\n`; xml += ` \n`; for (const test of this.tests) { xml += ` \n`; if (test.status === 'failed') { xml += ` \n`; xml += ` ${this.escapeXml(test.details || '')}\n`; xml += ` \n`; } xml += ` \n`; } xml += ` \n`; xml += ``; return xml; } escapeXml(str) { return str.replace(/[<>&'"]/g, (c) => { switch (c) { case '<': return '<'; case '>': return '>'; case '&': return '&'; case '\'': return '''; case '"': return '"'; default: return c; } }); } getJSON() { return { startTime: this.startTime, endTime: new Date(), duration: (new Date() - this.startTime) / 1000, summary: this.summary, tests: this.tests }; } } // Test assertions class MobileNavAssertions { static async assertMobileNavExists(page, device) { // Wait for mobile navigation to be dynamically created try { await page.waitForSelector('.ldr-mobile-bottom-nav', { timeout: 5000, visible: false // Element may exist but not be visible initially }); } catch { throw new Error(`Mobile navigation not found on ${device} after 5s wait`); } return true; } static async assertMobileNavVisible(page, device, shouldBeVisible) { const isVisible = await page.evaluate(() => { const nav = document.querySelector('.ldr-mobile-bottom-nav'); if (!nav) return false; const style = window.getComputedStyle(nav); return style.display !== 'none' && nav.classList.contains('visible'); }); if (isVisible !== shouldBeVisible) { throw new Error(`Mobile nav visibility mismatch on ${device}. Expected: ${shouldBeVisible}, Got: ${isVisible}`); } return true; } static async assertSidebarHidden(page, device, shouldBeHidden) { const sidebarInfo = await page.evaluate(() => { const sidebar = document.querySelector('.ldr-sidebar, aside.ldr-sidebar'); if (!sidebar) return { exists: false, isHidden: true }; const style = window.getComputedStyle(sidebar); const rect = sidebar.getBoundingClientRect(); return { exists: true, isHidden: style.display === 'none' || style.visibility === 'hidden' || style.transform.includes('translateX(-100%)') || style.transform.includes('translateX(-') || rect.left < -100, display: style.display, visibility: style.visibility, transform: style.transform, position: `${rect.left},${rect.top}` }; }); if (!sidebarInfo.exists) { console.log(` ✓ Sidebar doesn't exist in DOM (good for mobile)`); return true; } if (sidebarInfo.isHidden !== shouldBeHidden) { const details = `Display: ${sidebarInfo.display}, Visibility: ${sidebarInfo.visibility}, Transform: ${sidebarInfo.transform}, Position: ${sidebarInfo.position}`; throw new Error(`Sidebar visibility mismatch on ${device}. Expected hidden: ${shouldBeHidden}, Got: ${!sidebarInfo.isHidden}\nDetails: ${details}`); } return true; } static async assertTabCount(page, expectedCount) { // Wait for tabs to be created await page.waitForSelector('.ldr-mobile-nav-tab', { timeout: 5000 }); const tabCount = await page.evaluate(() => { const tabs = document.querySelectorAll('.ldr-mobile-nav-tab'); return tabs.length; }); if (tabCount !== expectedCount) { throw new Error(`Tab count mismatch. Expected: ${expectedCount}, Got: ${tabCount}`); } return true; } static async assertActiveTab(page, expectedTab) { const activeTab = await page.evaluate(() => { const active = document.querySelector('.ldr-mobile-nav-tab.active'); return active ? active.dataset.tabId : null; }); if (activeTab !== expectedTab) { throw new Error(`Active tab mismatch. Expected: ${expectedTab}, Got: ${activeTab}`); } return true; } static async assertSheetMenuToggle(page) { // Skip this test in CI - sheet menu animations are flaky in headless if (process.env.CI) { console.log(' ⏭️ Skipping sheet menu test in CI (animation timing issues)'); return true; } // Click More button await page.click('.ldr-mobile-nav-tab[data-tab-id="more"]'); await new Promise(resolve => setTimeout(resolve, 500)); // Check if sheet is open const sheetOpen = await page.evaluate(() => { const sheet = document.querySelector('.ldr-mobile-sheet-menu'); return sheet && sheet.classList.contains('active'); }); if (!sheetOpen) { throw new Error('Sheet menu failed to open'); } // Close sheet await page.click('.ldr-mobile-sheet-overlay'); await new Promise(resolve => setTimeout(resolve, 500)); const sheetClosed = await page.evaluate(() => { const sheet = document.querySelector('.ldr-mobile-sheet-menu'); return sheet && !sheet.classList.contains('active'); }); if (!sheetClosed) { throw new Error('Sheet menu failed to close'); } return true; } static async assertNoOverlap(page) { const hasOverlap = await page.evaluate(() => { const searchInput = document.querySelector('input[type="text"], input[type="search"], textarea'); const sidebar = document.querySelector('.sidebar'); if (!searchInput || !sidebar) return false; const searchRect = searchInput.getBoundingClientRect(); const sidebarRect = sidebar.getBoundingClientRect(); // Check if sidebar overlaps search input return !(searchRect.right < sidebarRect.left || searchRect.left > sidebarRect.right || searchRect.bottom < sidebarRect.top || searchRect.top > sidebarRect.bottom); }); if (hasOverlap) { throw new Error('Sidebar overlaps with input elements (Issue #667)'); } return true; } } // Main test runner class MobileNavTestRunner { constructor() { this.results = new TestResults(); this.browser = null; } async setup() { // Check server availability try { const response = await fetch(TEST_CONFIG.baseUrl); if (!response.ok) { throw new Error(`Server not responding at ${TEST_CONFIG.baseUrl}`); } } catch (error) { console.error(`❌ Server check failed: ${error.message}`); process.exit(1); } this.browser = await puppeteer.launch(getPuppeteerLaunchOptions({ headless: TEST_CONFIG.headless })); } async teardown() { if (this.browser) { await this.browser.close(); } } async runTest(testName, testFn, context = {}) { const startTime = Date.now(); const test = { name: testName, suite: context.suite || 'MobileNavigation', device: context.device, status: 'passed', error: null, details: null, duration: 0 }; try { await Promise.race([ testFn(), new Promise((_, reject) => setTimeout(() => reject(new Error(`Test "${testName}" timed out after 30s`)), 30000)) ]); if (process.env.VERBOSE) { console.log(` ✅ ${testName}`); } } catch (error) { test.status = 'failed'; test.error = error.message; test.details = error.stack; if (TEST_CONFIG.screenshotOnFailure && context.page) { try { const screenshotPath = `./test-failures/${context.device}_${testName.replace(/\s+/g, '_')}.png`; await fs.mkdir('./test-failures', { recursive: true }); // Add visual debugging based on test type if (testName.includes('sidebar') || testName.includes('overlap')) { await context.page.evaluate(() => { // Highlight sidebar in red if it exists const sidebar = document.querySelector('.ldr-sidebar, aside.ldr-sidebar'); if (sidebar) { sidebar.style.outline = '3px solid red'; sidebar.style.outlineOffset = '2px'; } // Highlight mobile nav in green const mobileNav = document.querySelector('.ldr-mobile-bottom-nav'); if (mobileNav) { mobileNav.style.outline = '3px solid lime'; } // Highlight any overlapping elements const inputs = document.querySelectorAll('input, textarea, select'); inputs.forEach(el => { el.style.outline = '2px dashed blue'; }); }); } if (testName.includes('tab') || testName.includes('navigation')) { await context.page.evaluate(() => { // Highlight active tab const activeTab = document.querySelector('.ldr-mobile-nav-tab.active'); if (activeTab) { activeTab.style.backgroundColor = 'yellow'; activeTab.style.color = 'black'; } // Number all tabs const tabs = document.querySelectorAll('.ldr-mobile-nav-tab'); tabs.forEach((tab, i) => { const badge = document.createElement('span'); badge.textContent = `${i + 1}`; badge.style.cssText = 'position:absolute;top:0;right:0;background:red;color:white;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:12px;'; tab.style.position = 'relative'; tab.appendChild(badge); }); }); } // Add timeout to screenshot to prevent hanging await context.page.screenshot({ path: screenshotPath, fullPage: true, timeout: 5000 // 5 second timeout for screenshot }); test.screenshot = screenshotPath; console.log(` 📸 Screenshot saved: ${screenshotPath}`); } catch (screenshotError) { console.error(`Warning: Failed to capture screenshot: ${screenshotError.message}`); // Don't fail the test because of screenshot failure } } console.error(` ❌ ${testName}: ${error.message}`); } finally { test.duration = (Date.now() - startTime) / 1000; this.results.addTest(test); } } async testDevice(deviceName, viewport) { console.log(`\nTesting ${deviceName}...`); const page = await this.browser.newPage(); await page.setViewport(viewport); const context = { device: deviceName, page, suite: `MobileNav.${deviceName}` }; try { // First authenticate to test on authenticated pages const AuthHelper = require('../auth_helper'); const authHelper = new AuthHelper(page, TEST_CONFIG.baseUrl); await authHelper.ensureAuthenticatedWithTimeout(); // Navigate to home page after authentication await page.goto(TEST_CONFIG.baseUrl, { waitUntil: 'domcontentloaded', timeout: TEST_CONFIG.timeout }); // Ensure mobile navigation script has loaded await page.waitForFunction(() => window.mobileNav !== undefined, { timeout: 5000 }); // Test 1: Mobile nav visibility based on viewport const shouldShowMobileNav = viewport.width <= 767; await this.runTest( `Mobile nav visibility (${viewport.width}px)`, async () => { if (shouldShowMobileNav) { await MobileNavAssertions.assertMobileNavExists(page, deviceName); await MobileNavAssertions.assertMobileNavVisible(page, deviceName, true); } else { await MobileNavAssertions.assertMobileNavVisible(page, deviceName, false); } }, context ); // Test 2: Sidebar hidden on mobile await this.runTest( 'Sidebar hidden on mobile', async () => { // Skip iPad Mini sidebar test in CI - rendering inconsistencies if (process.env.CI && deviceName === 'iPad_Mini') { console.log(' ⏭️ Skipping iPad Mini sidebar test in CI (rendering inconsistencies)'); return; } const shouldHideSidebar = viewport.width <= 767; await MobileNavAssertions.assertSidebarHidden(page, deviceName, shouldHideSidebar); }, context ); // Test 3: No overlap with input elements (Issue #667) await this.runTest( 'No sidebar overlap with inputs', async () => { await MobileNavAssertions.assertNoOverlap(page); }, context ); // Test 3b: Also test login page for overlap (Issue #667) await this.runTest( 'No sidebar overlap on login page', async () => { // Navigate to login page await page.goto(`${TEST_CONFIG.baseUrl}/auth/login`, { waitUntil: 'domcontentloaded', timeout: TEST_CONFIG.timeout }); await MobileNavAssertions.assertNoOverlap(page); // Navigate back to home await page.goto(TEST_CONFIG.baseUrl, { waitUntil: 'domcontentloaded', timeout: TEST_CONFIG.timeout }); }, context ); // Mobile-specific tests if (shouldShowMobileNav) { // Test 4: Correct number of tabs await this.runTest( 'Correct tab count', async () => { await MobileNavAssertions.assertTabCount(page, 5); }, context ); // Test 5: Sheet menu toggle await this.runTest( 'Sheet menu toggle', async () => { await MobileNavAssertions.assertSheetMenuToggle(page); }, context ); // Test 6: Navigation between pages await this.runTest( 'Tab navigation', async () => { // Skip navigation test in CI - timing issues with navigation if (process.env.CI) { console.log(' ⏭️ Skipping tab navigation test in CI (navigation timing issues)'); return; } // Click History tab const [_response] = await Promise.all([ page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 10000 }), page.click('.ldr-mobile-nav-tab[data-tab-id="history"]') ]); const url = page.url(); if (!url.includes('/history')) { throw new Error(`Navigation failed. Expected /history, got ${url}`); } // Check active tab await MobileNavAssertions.assertActiveTab(page, 'history'); }, context ); } } finally { await page.close(); } } async run() { console.log('🚀 Mobile Navigation CI/CD Test Suite'); console.log('====================================='); console.log(`Base URL: ${TEST_CONFIG.baseUrl}`); console.log(`Output Format: ${TEST_CONFIG.outputFormat}`); console.log(''); await this.setup(); try { // Test each device for (const [deviceName, viewport] of Object.entries(DEVICES)) { await this.testDevice(deviceName, viewport); } } finally { await this.teardown(); } // Output results await this.outputResults(); // Exit with appropriate code process.exit(this.results.summary.failed > 0 ? 1 : 0); } async outputResults() { console.log('\n====================================='); console.log('TEST RESULTS'); console.log('====================================='); console.log(`Total: ${this.results.summary.total}`); console.log(`Passed: ${this.results.summary.passed}`); console.log(`Failed: ${this.results.summary.failed}`); console.log(`Duration: ${((new Date() - this.results.startTime) / 1000).toFixed(2)}s`); // Save results based on output format if (TEST_CONFIG.outputFormat === 'junit') { const junitPath = './test-results.xml'; await fs.writeFile(junitPath, this.results.getJUnit()); console.log(`\nJUnit results saved to: ${junitPath}`); } else if (TEST_CONFIG.outputFormat === 'json') { const jsonPath = './test-results.json'; await fs.writeFile(jsonPath, JSON.stringify(this.results.getJSON(), null, 2)); console.log(`\nJSON results saved to: ${jsonPath}`); } // Always output to console if there are failures if (this.results.summary.failed > 0) { console.log('\n❌ FAILED TESTS:'); for (const test of this.results.tests) { if (test.status === 'failed') { console.log(` - ${test.suite}.${test.name}: ${test.error}`); if (test.screenshot) { console.log(` Screenshot: ${test.screenshot}`); } } } } } } // Run tests if executed directly if (require.main === module) { const runner = new MobileNavTestRunner(); runner.run().catch(error => { console.error('Test runner failed:', error); process.exit(1); }); } module.exports = { MobileNavTestRunner, MobileNavAssertions, TestResults };