#!/usr/bin/env node /** * UI Functionality Tests for CI * * Comprehensive automated tests for UI functionality including: * - Form validation and submission * - Dropdown interactions * - Modal/dialog behavior * - Navigation functionality * - Scroll behavior * - Button states * * Run: node test_ui_functionality_ci.js * * Environment variables: * - BASE_URL: Server URL (default: http://127.0.0.1:5000) * - CI: Set to 'true' for CI mode * - HEADLESS: 'true' or 'false' (default: true) */ const { setupTest, teardownTest, TestResults, log, delay, waitForVisible, config, navigateTo, withTimeout } = require('../test_lib'); // ============================================================================ // Form Tests // ============================================================================ const FormTests = { async researchFormStructure(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(() => { const form = document.querySelector('form'); const queryInput = document.querySelector('#query, textarea[name="query"], input[name="query"]'); const submitBtn = document.querySelector('button[type="submit"], input[type="submit"]'); return { formExists: !!form, queryInputExists: !!queryInput, submitBtnExists: !!submitBtn, queryInputType: queryInput?.tagName?.toLowerCase(), submitBtnText: submitBtn?.textContent?.trim() }; }); const passed = result.formExists && result.queryInputExists && result.submitBtnExists; return { passed, message: passed ? `Research form complete (query: ${result.queryInputType}, submit: "${result.submitBtnText}")` : `Missing elements: form=${result.formExists}, query=${result.queryInputExists}, submit=${result.submitBtnExists}` }; }, async inputFocusStates(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(() => { const input = document.querySelector('#query, textarea[name="query"], input[name="query"]'); if (!input) return { exists: false }; const initialOutline = window.getComputedStyle(input).outline; const initialBoxShadow = window.getComputedStyle(input).boxShadow; const initialBorderColor = window.getComputedStyle(input).borderColor; const initialBackgroundColor = window.getComputedStyle(input).backgroundColor; input.focus(); // Give browser time to apply focus styles const focusedOutline = window.getComputedStyle(input).outline; const focusedBoxShadow = window.getComputedStyle(input).boxShadow; const focusedBorderColor = window.getComputedStyle(input).borderColor; const focusedBackgroundColor = window.getComputedStyle(input).backgroundColor; const hasVisualChange = initialOutline !== focusedOutline || initialBoxShadow !== focusedBoxShadow || initialBorderColor !== focusedBorderColor || initialBackgroundColor !== focusedBackgroundColor; // Also check if input is focusable (a valid accessibility check) const isFocusable = document.activeElement === input; return { exists: true, hasVisualChange, isFocusable }; }); if (!result.exists) { return { passed: false, message: 'Query input not found' }; } // Pass if input is focusable, even if no visual change is detected // (visual changes may depend on CSS frameworks/themes) const passed = result.isFocusable || result.hasVisualChange; return { passed, message: passed ? result.hasVisualChange ? 'Input shows visible focus state' : 'Input is focusable (visual focus style may vary by theme)' : 'Input not focusable (accessibility issue)' }; }, async settingsFormButtons(page, baseUrl) { await navigateTo(page, `${baseUrl}/settings/`); const result = await page.evaluate(() => { const saveBtns = Array.from(document.querySelectorAll('button, input[type="submit"]')).filter(btn => btn.textContent?.toLowerCase().includes('save') || btn.type === 'submit' || btn.value?.toLowerCase().includes('save') ); const resetBtns = Array.from(document.querySelectorAll('button, input[type="reset"]')).filter(btn => btn.textContent?.toLowerCase().includes('reset') || btn.type === 'reset' || btn.value?.toLowerCase().includes('reset') ); return { saveBtnExists: saveBtns.length > 0, resetBtnExists: resetBtns.length > 0, saveBtnText: saveBtns[0]?.textContent?.trim() || saveBtns[0]?.value, resetBtnText: resetBtns[0]?.textContent?.trim() || resetBtns[0]?.value }; }); return { passed: result.saveBtnExists, message: result.saveBtnExists ? `Settings form has save button ("${result.saveBtnText}")${result.resetBtnExists ? ` and reset ("${result.resetBtnText}")` : ''}` : 'Settings form missing save button' }; } }; // ============================================================================ // Dropdown Tests // ============================================================================ const DropdownTests = { async dropdownStructure(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(() => { const dropdown = document.querySelector('.ldr-custom-dropdown'); if (!dropdown) return { exists: false }; const input = dropdown.querySelector('input'); const list = dropdown.querySelector('.ldr-custom-dropdown-list, [class*="dropdown-list"]'); const hiddenInput = dropdown.querySelector('input[type="hidden"]'); return { exists: true, hasInput: !!input, hasList: !!list, hasHiddenInput: !!hiddenInput, listItemCount: list?.querySelectorAll('li, .dropdown-item').length || 0 }; }); // If dropdown doesn't exist, this is a real skip - not a pass if (!result.exists) { return { passed: null, skipped: true, message: 'No custom dropdown on this page' }; } const hasRequiredParts = result.hasInput && result.hasList; return { passed: hasRequiredParts, message: hasRequiredParts ? `Dropdown structure valid (${result.listItemCount} items, hidden input: ${result.hasHiddenInput})` : `Dropdown missing parts: input=${result.hasInput}, list=${result.hasList}` }; }, async dropdownOpensOnClick(page, baseUrl) { // Clicking a custom-dropdown input runs custom_dropdown.js::showDropdown, // which sets the list to display:block, adds .ldr-dropdown-active, AND // reparents the list to document.body. We target the search-engine // dropdown specifically (#search_engine): its options come from // /settings/api/available-search-engines (no LLM), unlike the model // dropdown which is empty without a configured provider. Because the list // is moved to , read it by id (#search-engine-dropdown-list), not as // a descendant of .ldr-custom-dropdown. The old test had OR/skip fallbacks // that passed even when the dropdown never opened. await navigateTo(page, `${baseUrl}/`); await page.waitForSelector('#search_engine', { timeout: 15000 }); // research.js sets up #search_engine TWICE: synchronously on load and again // inside Promise.all([...loadSearchEngineOptions]).then() once the fetch // resolves (research.js:329-344). Every setupCustomDropdown() ends with // hideDropdown() (custom_dropdown.js:629), so a click before that async // re-setup gets force-closed. navigateTo only waits for domcontentloaded, // so wait for the network to settle (the home page has no SSE/polling) to // guarantee the final setup has run before we open the dropdown. await page.waitForNetworkIdle({ idleTime: 500, timeout: 15000 }).catch(() => {}); await page.click('#search_engine'); await page.waitForFunction(() => { const list = document.querySelector('#search-engine-dropdown-list'); return list && window.getComputedStyle(list).display === 'block' && list.classList.contains('ldr-dropdown-active'); }, { timeout: 5000 }).catch(() => {}); const result = await page.evaluate(() => { const list = document.querySelector('#search-engine-dropdown-list'); if (!list) return { hasList: false }; return { hasList: true, display: window.getComputedStyle(list).display, active: list.classList.contains('ldr-dropdown-active'), itemCount: list.querySelectorAll('.ldr-custom-dropdown-item, [role="option"], li').length, }; }); if (!result.hasList) { return { passed: false, message: '#search-engine-dropdown-list not found on /' }; } const passed = result.display === 'block' && result.active; return { passed, message: passed ? `Search-engine dropdown opens on click (display=${result.display}, active=${result.active}, items=${result.itemCount})` : `Dropdown did not open on click (display=${result.display}, active=${result.active})` }; }, async dropdownSelection(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(() => { const dropdown = document.querySelector('.ldr-custom-dropdown'); if (!dropdown) return { exists: false }; const hiddenInput = dropdown.querySelector('input[type="hidden"]'); const items = dropdown.querySelectorAll('.ldr-custom-dropdown-list li, [class*="dropdown-list"] li, .dropdown-item'); return { exists: true, hasHiddenInput: !!hiddenInput, hiddenInputName: hiddenInput?.name, hiddenInputValue: hiddenInput?.value, itemCount: items.length }; }); if (!result.exists) { return { passed: null, skipped: true, message: 'No dropdown on this page' }; } return { passed: result.hasHiddenInput, message: result.hasHiddenInput ? `Dropdown has hidden input (name: ${result.hiddenInputName}, value: ${result.hiddenInputValue})` : 'Dropdown missing hidden input for form submission' }; } }; // ============================================================================ // Modal Tests // ============================================================================ const ModalTests = { async deleteModalStructure(page, baseUrl) { // #deleteConfirmModal is a static include in library.html (line 1140 → // components/delete_confirmation_modal.html), present-but-hidden. The old // test navigated to /history/ where it doesn't exist (→ permanent skip). // Assert the real modal structure on /library/. await navigateTo(page, `${baseUrl}/library/`); await page.waitForSelector('#deleteConfirmModal', { timeout: 15000 }); const result = await page.evaluate(() => { const modal = document.querySelector('#deleteConfirmModal'); if (!modal) return { exists: false }; return { exists: true, hasTitle: !!modal.querySelector('#deleteConfirmModalLabel'), hasConfirm: !!modal.querySelector('#deleteConfirmBtn'), hasCancel: !!modal.querySelector('.btn-secondary[data-bs-dismiss="modal"]'), }; }); if (!result.exists) { return { passed: false, message: '#deleteConfirmModal not found on /library/' }; } const passed = result.hasTitle && result.hasConfirm && result.hasCancel; return { passed, message: passed ? 'Delete modal has title + confirm (#deleteConfirmBtn) + cancel (data-bs-dismiss) buttons' : `Delete modal incomplete (title=${result.hasTitle}, confirm=${result.hasConfirm}, cancel=${result.hasCancel})` }; }, async modalZIndex(page, baseUrl) { // #deleteConfirmModal carries an inline z-index: 10000 // (components/delete_confirmation_modal.html), included in library.html. // The old test navigated to /history/ (modal absent → skip). Assert the // z-index on /library/ so the modal sits above the app chrome. We check // BOTH an absolute floor and a relative comparison: the mobile bottom nav // is z-index 1500 (mobile-responsive.css), already above a bare 1000 floor, // so the relative check (modal > sidebar AND modal > nav) is what actually // guarantees the modal overlays the chrome. getComputedStyle is only called // on elements that exist (getComputedStyle(null) throws); absent chrome → 0. await navigateTo(page, `${baseUrl}/library/`); await page.waitForSelector('#deleteConfirmModal', { timeout: 15000 }); const result = await page.evaluate(() => { const modal = document.querySelector('#deleteConfirmModal'); if (!modal) return { exists: false }; const zOf = (sel) => { const el = document.querySelector(sel); if (!el) return 0; const z = parseInt(window.getComputedStyle(el).zIndex, 10); return Number.isFinite(z) ? z : 0; }; const z = parseInt(window.getComputedStyle(modal).zIndex, 10); return { exists: true, zIndex: Number.isFinite(z) ? z : 0, sidebarZ: zOf('.ldr-sidebar'), navZ: zOf('.ldr-mobile-bottom-nav'), }; }); if (!result.exists) { return { passed: false, message: '#deleteConfirmModal not found on /library/' }; } const aboveChrome = result.zIndex > result.sidebarZ && result.zIndex > result.navZ; const passed = result.zIndex >= 1000 && aboveChrome; return { passed, message: passed ? `Delete modal z-index ${result.zIndex} >= 1000 and above chrome (sidebar=${result.sidebarZ}, nav=${result.navZ})` : `Delete modal z-index ${result.zIndex} fails (>=1000 && >sidebar(${result.sidebarZ}) && >nav(${result.navZ}))` }; } }; // ============================================================================ // Navigation Tests // ============================================================================ const NavigationTests = { async sidebarLinksClickable(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(() => { const sidebar = document.querySelector('.ldr-sidebar'); if (!sidebar) return { exists: false }; const links = Array.from(sidebar.querySelectorAll('a[href]')); const clickableLinks = links.filter(link => { const rect = link.getBoundingClientRect(); const style = window.getComputedStyle(link); return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && style.pointerEvents !== 'none'; }); return { exists: true, totalLinks: links.length, clickableLinks: clickableLinks.length }; }); if (!result.exists) { return { passed: null, skipped: true, message: 'No sidebar on this page' }; } return { passed: result.clickableLinks > 0, message: result.clickableLinks > 0 ? `${result.clickableLinks}/${result.totalLinks} sidebar links are clickable` : 'No clickable sidebar links found' }; }, async activePageHighlighted(page, baseUrl) { const pages = [ { url: '/', name: 'Research' }, { url: '/history/', name: 'History' }, { url: '/settings/', name: 'Settings' } ]; let passCount = 0; const details = []; for (const pageInfo of pages) { await navigateTo(page, `${baseUrl}${pageInfo.url}`); const hasActive = await page.evaluate(() => { const sidebar = document.querySelector('.ldr-sidebar'); if (!sidebar) return { hasSidebar: false }; const activeLinks = sidebar.querySelectorAll('.active, [class*="active"], [aria-current="page"]'); return { hasSidebar: true, hasActiveClass: activeLinks.length > 0 }; }, pageInfo.url); if (hasActive.hasSidebar && hasActive.hasActiveClass) { passCount++; details.push(`${pageInfo.name}: OK`); } else { details.push(`${pageInfo.name}: MISSING`); } } // All pages should have active highlighting const allPassed = passCount === pages.length; return { passed: allPassed, message: `Active state highlighting: ${details.join(', ')}` }; }, async mobileNavTabs(page, baseUrl) { await page.setViewport({ width: 375, height: 667, isMobile: true }); await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(() => { const mobileNav = document.querySelector('.ldr-mobile-bottom-nav'); if (!mobileNav) return { exists: false }; const tabs = mobileNav.querySelectorAll('.ldr-mobile-nav-tab, [class*="nav-tab"], button, a'); const clickableTabs = Array.from(tabs).filter(tab => { const rect = tab.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }); return { exists: true, tabCount: clickableTabs.length, tabLabels: clickableTabs.slice(0, 5).map(t => t.textContent?.trim() || t.getAttribute('aria-label')) }; }); // Reset viewport await page.setViewport({ width: 1280, height: 800 }); if (!result.exists) { return { passed: null, skipped: true, message: 'No mobile nav on desktop viewport' }; } return { passed: result.tabCount >= 2, message: result.tabCount >= 2 ? `Mobile nav has ${result.tabCount} tabs: ${result.tabLabels.join(', ')}` : `Mobile nav has insufficient tabs (${result.tabCount})` }; } }; // ============================================================================ // Scroll Tests // ============================================================================ const ScrollTests = { async sidebarScrollable(page, baseUrl) { await page.setViewport({ width: 1280, height: 500 }); // Short viewport await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(() => { const sidebar = document.querySelector('.ldr-sidebar'); if (!sidebar) return { exists: false }; const nav = sidebar.querySelector('.ldr-sidebar-nav, nav, ul'); if (!nav) return { exists: true, hasNav: false }; const style = window.getComputedStyle(nav); const parentStyle = window.getComputedStyle(sidebar); return { exists: true, hasNav: true, navScrollHeight: nav.scrollHeight, navClientHeight: nav.clientHeight, isOverflowing: nav.scrollHeight > nav.clientHeight, overflowY: style.overflowY, parentOverflowY: parentStyle.overflowY }; }); // Reset viewport await page.setViewport({ width: 1280, height: 800 }); if (!result.exists || !result.hasNav) { return { passed: null, skipped: true, message: 'No sidebar nav to test' }; } const isScrollable = result.overflowY === 'auto' || result.overflowY === 'scroll' || result.parentOverflowY === 'auto' || result.parentOverflowY === 'scroll'; return { passed: !result.isOverflowing || isScrollable, message: result.isOverflowing ? (isScrollable ? 'Sidebar nav is scrollable when content overflows' : 'Sidebar nav overflows but is not scrollable') : 'Sidebar nav fits without scrolling' }; }, async contentNotCutOff(page, baseUrl) { await navigateTo(page, `${baseUrl}/settings/`); const result = await page.evaluate(() => { const mainEl = document.querySelector('main, .main-content, .container'); if (!mainEl) return { exists: false }; const allElements = mainEl.querySelectorAll('*'); let lastBottom = 0; allElements.forEach(el => { const rect = el.getBoundingClientRect(); const style = window.getComputedStyle(el); if (rect.height > 0 && style.display !== 'none' && rect.bottom > lastBottom) { lastBottom = rect.bottom; } }); const viewportHeight = window.innerHeight; const bodyHeight = document.body.scrollHeight; const isScrollable = bodyHeight > viewportHeight; // Content is cut off if last element bottom exceeds viewport AND page is not scrollable const isCutOff = lastBottom > viewportHeight && !isScrollable; return { exists: true, lastBottom, viewportHeight, bodyHeight, isScrollable, isCutOff }; }); if (!result.exists) { return { passed: null, skipped: true, message: 'No main content found' }; } return { passed: !result.isCutOff, message: result.isCutOff ? `Content is cut off at ${result.lastBottom}px (viewport: ${result.viewportHeight}px, not scrollable)` : result.isScrollable ? `Page is scrollable (body: ${result.bodyHeight}px, viewport: ${result.viewportHeight}px)` : `Page fits in viewport (last element at ${Math.round(result.lastBottom)}px)` }; } }; // ============================================================================ // Button State Tests // ============================================================================ const ButtonTests = { async buttonStates(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(() => { const buttons = document.querySelectorAll('button, .btn, input[type="submit"]'); if (buttons.length === 0) return { exists: false }; const btn = buttons[0]; const initialCursor = window.getComputedStyle(btn).cursor; return { exists: true, buttonCount: buttons.length, hasCursorPointer: initialCursor === 'pointer' }; }); if (!result.exists) { return { passed: false, message: 'No buttons found on page' }; } return { passed: result.hasCursorPointer, message: result.hasCursorPointer ? `Buttons have pointer cursor (${result.buttonCount} buttons found)` : 'Buttons may lack proper cursor style' }; }, async disabledButtonStyle(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(() => { const disabledBtns = document.querySelectorAll('button[disabled], .btn[disabled], button.disabled, .btn.disabled'); if (disabledBtns.length === 0) return { hasDisabled: false }; const btn = disabledBtns[0]; const style = window.getComputedStyle(btn); return { hasDisabled: true, opacity: style.opacity, cursor: style.cursor, pointerEvents: style.pointerEvents }; }); if (!result.hasDisabled) { return { passed: null, skipped: true, message: 'No disabled buttons to test' }; } const isProperlyStyled = parseFloat(result.opacity) < 1 || result.cursor === 'not-allowed' || result.pointerEvents === 'none'; return { passed: isProperlyStyled, message: isProperlyStyled ? `Disabled buttons styled correctly (opacity: ${result.opacity}, cursor: ${result.cursor})` : 'Disabled buttons may not be visually distinct' }; } }; // ============================================================================ // Main Test Runner // ============================================================================ async function main() { log.section('UI Functionality Tests'); const ctx = await setupTest({ authenticate: true, viewport: { width: 1280, height: 800 } }); const results = new TestResults('UI Functionality Tests'); const { page } = ctx; const { baseUrl } = ctx.config; async function run(category, name, testFn) { try { const result = await withTimeout( testFn(page, baseUrl), 30000, `${category}/${name}` ); if (result.skipped) { results.skip(category, name, result.message); } else { results.add(category, name, result.passed, result.message); } } catch (error) { results.add(category, name, false, `Error: ${error.message}`); } } try { // Form Tests log.section('Form Tests'); await run('Forms', 'Research Form Structure', (p, u) => FormTests.researchFormStructure(p, u)); await run('Forms', 'Input Focus States', (p, u) => FormTests.inputFocusStates(p, u)); await run('Forms', 'Settings Form Buttons', (p, u) => FormTests.settingsFormButtons(p, u)); // Dropdown Tests log.section('Dropdown Tests'); await run('Dropdowns', 'Dropdown Structure', (p, u) => DropdownTests.dropdownStructure(p, u)); await run('Dropdowns', 'Dropdown Opens On Click', (p, u) => DropdownTests.dropdownOpensOnClick(p, u)); await run('Dropdowns', 'Dropdown Selection', (p, u) => DropdownTests.dropdownSelection(p, u)); // Modal Tests log.section('Modal Tests'); await run('Modals', 'Delete Modal Structure', (p, u) => ModalTests.deleteModalStructure(p, u)); await run('Modals', 'Modal Z-Index', (p, u) => ModalTests.modalZIndex(p, u)); // Navigation Tests log.section('Navigation Tests'); await run('Navigation', 'Sidebar Links Clickable', (p, u) => NavigationTests.sidebarLinksClickable(p, u)); await run('Navigation', 'Active Page Highlighted', (p, u) => NavigationTests.activePageHighlighted(p, u)); await run('Navigation', 'Mobile Nav Tabs', (p, u) => NavigationTests.mobileNavTabs(p, u)); // Scroll Tests log.section('Scroll Tests'); await run('Scroll', 'Sidebar Scrollable', (p, u) => ScrollTests.sidebarScrollable(p, u)); await run('Scroll', 'Content Not Cut Off', (p, u) => ScrollTests.contentNotCutOff(p, u)); // Button Tests log.section('Button Tests'); await run('Buttons', 'Button States', (p, u) => ButtonTests.buttonStates(p, u)); await run('Buttons', 'Disabled Button Style', (p, u) => ButtonTests.disabledButtonStyle(p, u)); } catch (error) { log.error(`Fatal error: ${error.message}`); console.error(error.stack); } finally { results.print(); results.save(); await teardownTest(ctx); process.exit(results.exitCode()); } } // Run if executed directly if (require.main === module) { main().catch(error => { console.error('Test runner failed:', error); process.exit(1); }); } module.exports = { FormTests, DropdownTests, ModalTests, NavigationTests, ScrollTests, ButtonTests };