/** * Comprehensive Responsive UI Testing * Tests UI responsiveness across different viewport sizes */ const puppeteer = require('puppeteer'); const AuthHelper = require('./auth_helper'); const { getPuppeteerLaunchOptions } = require('./puppeteer_config'); const fs = require('fs'); const path = require('path'); // Viewport configurations const VIEWPORTS = { mobile: { width: 375, height: 667, deviceScaleFactor: 2, isMobile: true, hasTouch: true }, tablet: { width: 768, height: 1024, deviceScaleFactor: 2, isMobile: true, hasTouch: true }, desktop: { width: 1280, height: 720, deviceScaleFactor: 1, isMobile: false, hasTouch: false } }; // Pages to test - expanded to include subpages const TEST_PAGES = [ // Main pages { name: 'Home', path: '/' }, { name: 'Settings', path: '/settings/' }, { name: 'Metrics', path: '/metrics/' }, { name: 'History', path: '/history/' }, { name: 'News', path: '/news/' }, // Metrics subpages (actual routes from metrics_routes.py) { name: 'Metrics-ContextOverflow', path: '/metrics/context-overflow' }, { name: 'Metrics-Costs', path: '/metrics/costs' }, { name: 'Metrics-StarReviews', path: '/metrics/star-reviews' }, { name: 'Metrics-Links', path: '/metrics/links' }, // News subpages { name: 'News-Subscriptions', path: '/news/subscriptions' }, { name: 'News-NewSubscription', path: '/news/subscriptions/new' }, // Other important pages { name: 'Benchmark', path: '/benchmark/' }, { name: 'BenchmarkResults', path: '/benchmark/results' }, // Note: Research results pages require a research_id so can't be tested generically // e.g. /results/, /progress/, /details/ ]; class ResponsiveUITester { constructor(viewport = 'desktop') { this.viewport = viewport; this.baseUrl = process.env.TEST_BASE_URL || 'http://127.0.0.1:5000'; this.results = { passed: 0, failed: 0, warnings: 0, issues: [] }; // Create screenshots directory this.screenshotsDir = path.join(__dirname, 'screenshots', viewport); if (!process.env.CI && !fs.existsSync(this.screenshotsDir)) { fs.mkdirSync(this.screenshotsDir, { recursive: true }); } // Visual mode for debugging this.visualMode = process.env.VISUAL_MODE === 'true' || process.env.HEADLESS === 'false'; } /** * Take a screenshot with a graceful fallback when the page exceeds * Puppeteer's fullPage screenshot ceiling (16384px tall on Chromium). * * The diagnostic screenshots in this test are nice-to-have, not the * test target — failing to capture a screenshot on an over-tall page * (Settings / Metrics on narrow mobile viewports) used to bubble up to * `testPage`'s catch block and mark the whole page as failed. This * helper preserves the fullPage attempt where possible and falls back * to a viewport-only screenshot on the documented "Page is too large" * protocol error so the run continues and the failure surface stays * about real responsive issues. */ async safeScreenshot(opts) { try { await this.page.screenshot(opts); return true; } catch (error) { const msg = error && error.message ? error.message : String(error); if (msg.includes('Page is too large') || msg.includes('captureScreenshot')) { try { await this.page.screenshot({ ...opts, fullPage: false }); console.log(` ℹ️ Page exceeded screenshot size limit; saved viewport-only fallback (${opts.path})`); return true; } catch (fallbackError) { console.log(` ⚠️ Screenshot fallback also failed: ${fallbackError.message}`); return false; } } console.log(` ⚠️ Screenshot failed: ${msg}`); return false; } } async init() { console.log('Launching browser...'); this.browser = await puppeteer.launch(getPuppeteerLaunchOptions()); console.log('Creating new page...'); this.page = await this.browser.newPage(); await this.page.setViewport(VIEWPORTS[this.viewport]); // First, capture login page screenshots before authentication await this.captureLoginPageScreenshots(); // Authenticate with timeout protection console.log('Authenticating...'); const auth = new AuthHelper(this.page, this.baseUrl); // Use a reasonable timeout for authentication const authTimeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Authentication timeout')), 30000) ); try { // For CI, use the pre-created test user from the workflow if (process.env.CI) { const testUser = { username: 'test_admin', password: 'testpass123' // pragma: allowlist secret }; console.log(`Using pre-created test user: ${testUser.username}`); await Promise.race([ auth.ensureAuthenticated(testUser.username, testUser.password), authTimeout ]); } else { await Promise.race([ auth.ensureAuthenticated(), authTimeout ]); } console.log('Authentication successful'); } catch (error) { console.error('Authentication failed:', error.message); throw error; } } async captureLoginPageScreenshots() { console.log(`📸 Capturing login page screenshots for ${this.viewport} viewport...`); try { // Navigate to login page const loginUrl = `${this.baseUrl}/auth/login`; await this.page.goto(loginUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); // Wait for login form to be visible await this.page.waitForSelector('input[name="username"]', { timeout: 5000 }); // Take screenshot of the login page if (!process.env.CI) { const screenshotPath = path.join(this.screenshotsDir, `login-page-${this.viewport}.png`); await this.safeScreenshot({ path: screenshotPath, fullPage: true }); console.log(` ✅ Login page screenshot saved: ${screenshotPath}`); } // Check for responsive issues on login page const hasHorizontalOverflow = await this.page.evaluate(() => { return document.body.scrollWidth > window.innerWidth; }); if (hasHorizontalOverflow) { console.log(` ⚠️ Login page has horizontal overflow at ${this.viewport}`); if (!process.env.CI) { const overflowScreenshotPath = path.join(this.screenshotsDir, `login-page-overflow-${this.viewport}.png`); await this.safeScreenshot({ path: overflowScreenshotPath, fullPage: true }); console.log(` 📸 Overflow screenshot saved: ${overflowScreenshotPath}`); } } // Check touch target sizes for mobile if (this.viewport === 'mobile') { const hasSmallTouchTargets = await this.page.evaluate(() => { const clickables = document.querySelectorAll('button, input, a'); for (const el of clickables) { const rect = el.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0 && (rect.width < 44 || rect.height < 44)) { return true; } } return false; }); if (hasSmallTouchTargets) { console.log(` ⚠️ Login page has small touch targets for mobile`); if (!process.env.CI) { // Highlight small touch targets await this.page.evaluate(() => { const clickables = document.querySelectorAll('button, input, a'); clickables.forEach(el => { const rect = el.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0 && (rect.width < 44 || rect.height < 44)) { el.style.outline = '2px dashed red'; el.style.outlineOffset = '2px'; } }); }); const touchTargetScreenshotPath = path.join(this.screenshotsDir, `login-page-touch-targets-${this.viewport}.png`); await this.safeScreenshot({ path: touchTargetScreenshotPath, fullPage: true }); console.log(` 📸 Touch target screenshot saved: ${touchTargetScreenshotPath}`); } } } // Also capture registration page screenshot const registerUrl = `${this.baseUrl}/auth/register`; await this.page.goto(registerUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); // Wait for registration form to be visible await this.page.waitForSelector('input[name="username"]', { timeout: 5000 }); if (!process.env.CI) { const registerScreenshotPath = path.join(this.screenshotsDir, `register-page-${this.viewport}.png`); await this.safeScreenshot({ path: registerScreenshotPath, fullPage: true }); console.log(` ✅ Registration page screenshot saved: ${registerScreenshotPath}`); } console.log(`✅ Login/register page screenshots captured for ${this.viewport}`); } catch (error) { console.error(`❌ Error capturing login page screenshots: ${error.message}`); // Don't throw - this is not critical for the test to continue } } async testPage(pageInfo) { const url = `${this.baseUrl}${pageInfo.path}`; console.log(`Testing ${pageInfo.name} at ${this.viewport} viewport...`); try { const response = await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); // Check if page exists (skip 404s) if (response && response.status() === 404) { console.log(` ⚠️ Skipping ${pageInfo.name} - page not found (404)`); return; } // Check if we were redirected to login page (indicates session lost) const currentUrl = this.page.url(); if (currentUrl.includes('/auth/login') || currentUrl.includes('/auth/register')) { console.log(` ⚠️ Redirected to auth page - re-authenticating...`); // Re-authenticate if needed const authHelper = new AuthHelper(this.page); const authResult = await authHelper.ensureAuthenticated(); if (authResult.authenticated) { // Try navigating to the page again await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); } } // Check for horizontal overflow const hasHorizontalOverflow = await this.page.evaluate(() => { return document.body.scrollWidth > window.innerWidth; }); if (hasHorizontalOverflow) { this.results.warnings++; this.results.issues.push(`⚠️ ${pageInfo.name}: Horizontal overflow detected at ${this.viewport}`); // Take screenshot if not in CI if (!process.env.CI) { const screenshotPath = path.join(this.screenshotsDir, `${pageInfo.name.toLowerCase()}-overflow.png`); await this.safeScreenshot({ path: screenshotPath, fullPage: true }); console.log(` 📸 Screenshot saved: ${screenshotPath}`); } } // Check for overlapping elements const hasOverlaps = await this.checkForOverlaps(); if (hasOverlaps) { this.results.warnings++; this.results.issues.push(`⚠️ ${pageInfo.name}: Overlapping elements detected at ${this.viewport}`); // Highlight overlapping elements and take screenshot if not in CI if (!process.env.CI) { await this.page.evaluate(() => { const elements = document.querySelectorAll('button, a, input, select'); elements.forEach(el => { el.style.outline = '2px solid red'; }); }); const screenshotPath = path.join(this.screenshotsDir, `${pageInfo.name.toLowerCase()}-overlaps.png`); await this.safeScreenshot({ path: screenshotPath, fullPage: true }); console.log(` 📸 Screenshot saved: ${screenshotPath}`); } } // Check text readability const hasSmallText = await this.page.evaluate(() => { const elements = document.querySelectorAll('*'); for (const el of elements) { const style = window.getComputedStyle(el); const fontSize = parseFloat(style.fontSize); if (fontSize > 0 && fontSize < 12 && el.textContent.trim()) { return true; } } return false; }); if (hasSmallText && this.viewport === 'mobile') { this.results.warnings++; this.results.issues.push(`⚠️ ${pageInfo.name}: Text too small for mobile viewing`); // Highlight small text and take screenshot if not in CI if (!process.env.CI) { await this.page.evaluate(() => { const elements = document.querySelectorAll('*'); elements.forEach(el => { const style = window.getComputedStyle(el); const fontSize = parseFloat(style.fontSize); if (fontSize > 0 && fontSize < 12 && el.textContent.trim()) { el.style.backgroundColor = 'yellow'; el.style.border = '1px solid orange'; } }); }); const screenshotPath = path.join(this.screenshotsDir, `${pageInfo.name.toLowerCase()}-small-text.png`); await this.safeScreenshot({ path: screenshotPath, fullPage: true }); console.log(` 📸 Screenshot saved: ${screenshotPath}`); } } // Check touch target sizes for mobile if (this.viewport === 'mobile') { const hasSmallTouchTargets = await this.page.evaluate(() => { const clickables = document.querySelectorAll('button, a, input, select, textarea'); for (const el of clickables) { const rect = el.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0 && (rect.width < 44 || rect.height < 44)) { return true; } } return false; }); if (hasSmallTouchTargets) { this.results.warnings++; this.results.issues.push(`⚠️ ${pageInfo.name}: Touch targets too small (< 44px)`); // Highlight small touch targets and take screenshot if not in CI if (!process.env.CI) { await this.page.evaluate(() => { const clickables = document.querySelectorAll('button, a, input, select, textarea'); clickables.forEach(el => { const rect = el.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0 && (rect.width < 44 || rect.height < 44)) { el.style.outline = '2px dashed blue'; el.style.outlineOffset = '2px'; } }); }); const screenshotPath = path.join(this.screenshotsDir, `${pageInfo.name.toLowerCase()}-small-touch-targets.png`); await this.safeScreenshot({ path: screenshotPath, fullPage: true }); console.log(` 📸 Screenshot saved: ${screenshotPath}`); } } } this.results.passed++; console.log(`✅ ${pageInfo.name} passed basic responsive checks`); // Always capture screenshot for BenchmarkResults page if (pageInfo.name === 'BenchmarkResults' && !process.env.CI) { const screenshotPath = path.join(this.screenshotsDir, `benchmarkresults-page.png`); await this.safeScreenshot({ path: screenshotPath, fullPage: true }); console.log(` 📸 Screenshot saved: ${screenshotPath}`); } } catch (error) { this.results.failed++; this.results.issues.push(`❌ ${pageInfo.name}: ${error.message}`); console.error(`❌ Error testing ${pageInfo.name}:`, error.message); } } async checkForOverlaps() { return await this.page.evaluate(() => { const elements = document.querySelectorAll('button, a, input, select'); const rects = []; for (const el of elements) { const rect = el.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { rects.push(rect); } } // Check for overlaps for (let i = 0; i < rects.length; i++) { for (let j = i + 1; j < rects.length; j++) { const r1 = rects[i]; const r2 = rects[j]; const overlap = !(r1.right < r2.left || r1.left > r2.right || r1.bottom < r2.top || r1.top > r2.bottom); if (overlap) { return true; } } } return false; }); } async runTests() { await this.init(); console.log(`\n📱 Running Responsive UI Tests for ${this.viewport.toUpperCase()} viewport\n`); for (const pageInfo of TEST_PAGES) { await this.testPage(pageInfo); } await this.cleanup(); this.printResults(); } async cleanup() { if (this.browser) { await this.browser.close(); } } /** * Compare the warnings produced by this run against the checked-in * baseline (responsive_baseline.json). Three outcomes: * * 1. Issues match the baseline exactly → return null (test passes). * 2. New issues appeared that aren't in the baseline → REGRESSION. * Caller should fail the test loudly so a PR can't introduce a * new responsive bug without notice. * 3. Baseline issues are no longer present → BASELINE STALE. * Caller should fail the test so the contributor who fixed the * bug is forced to remove the entry; this prevents the baseline * from silently masking a future regression. * * Returning a structured result so the caller can format messaging. */ diffAgainstBaseline() { const baselinePath = path.join(__dirname, 'responsive_baseline.json'); let baseline; try { baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8')); } catch (err) { return { error: `Could not read responsive_baseline.json: ${err.message}`, }; } const expected = (baseline[this.viewport] || []).slice().sort(); // Strip the leading "⚠️ " emoji+space the test prepends so the JSON // file stays plain ASCII and easier to diff. const actual = this.results.issues .map((issue) => issue.replace(/^⚠️\s+/u, '')) .slice() .sort(); const newRegressions = actual.filter((i) => !expected.includes(i)); const fixedIssues = expected.filter((i) => !actual.includes(i)); return { newRegressions, fixedIssues }; } printResults() { console.log('\n' + '='.repeat(50)); console.log('RESPONSIVE UI TEST RESULTS'); console.log('='.repeat(50)); console.log(`Viewport: ${this.viewport.toUpperCase()}`); console.log(`✅ ${this.results.passed} passed`); console.log(`❌ ${this.results.failed} failed`); console.log(`⚠️ ${this.results.warnings} warnings`); if (this.results.issues.length > 0) { console.log('\nIMPROVEMENT SUGGESTIONS:'); this.results.issues.forEach(issue => { console.log(issue); }); } console.log('='.repeat(50)); // Page-level test failures (e.g. nav errors) always fail the run. if (this.results.failed > 0) { process.exit(1); } // Compare warnings against the checked-in baseline. New responsive // bugs fail the run; old bugs that are now fixed also fail so the // baseline must be kept honest. const diff = this.diffAgainstBaseline(); if (diff.error) { console.error(`\n❌ Baseline check failed: ${diff.error}`); process.exit(1); } if (diff.newRegressions.length > 0) { console.error( '\n❌ NEW responsive issues not in responsive_baseline.json ' + `(${this.viewport}):` ); diff.newRegressions.forEach((i) => console.error(` + ${i}`)); console.error( '\nIf this is an intentional change, add the lines above to ' + `responsive_baseline.json under "${this.viewport}". ` + 'Otherwise the PR is introducing a responsive regression.' ); process.exit(1); } // We *warn* but don't fail when baseline entries don't reproduce in // this run. Some warnings (notably touch-target detection on dynamic // content) are intermittent — failing on a missing one would be flaky. // If a maintainer has actually fixed an issue, they can confirm by // running the test a few times and then trim responsive_baseline.json // manually. if (diff.fixedIssues.length > 0) { console.warn( `\nℹ️ Some baseline entries did not reproduce this run ` + `(${this.viewport}):` ); diff.fixedIssues.forEach((i) => console.warn(` - ${i}`)); console.warn( `\nIf you've fixed these issues, please re-run the test 3+ ` + 'times and remove the entries above from responsive_baseline.json. ' + "(Not failing the run because some warnings are intermittent.)" ); } console.log( `\n✅ Responsive issues within baseline (${this.viewport}): ` + `${this.results.warnings} warning(s) this run, no regressions.` ); } } // Main execution async function main() { const viewport = process.argv[2] || 'desktop'; if (!VIEWPORTS[viewport]) { console.error(`Invalid viewport: ${viewport}. Use: mobile, tablet, or desktop`); process.exit(1); } const tester = new ResponsiveUITester(viewport); await tester.runTests(); } // Run if executed directly if (require.main === module) { main().catch(error => { console.error('Test execution failed:', error); process.exit(1); }); } module.exports = { ResponsiveUITester };