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
707 lines
29 KiB
JavaScript
707 lines
29 KiB
JavaScript
#!/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 <body>, 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 };
|