chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,706 @@
|
||||
#!/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 };
|
||||
Reference in New Issue
Block a user