#!/usr/bin/env node /** * Loading & Feedback UI Tests * * Tests for loading states, spinners, toasts, success/error messages, * and visual feedback mechanisms. * * Run: node test_loading_feedback_ci.js */ const { setupTest, teardownTest, TestResults, log, delay, navigateTo, withTimeout } = require('./test_lib'); // ============================================================================ // Loading States Tests // ============================================================================ const LoadingStatesTests = { async loadingSpinnersExist(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(() => { // Check CSS for spinner definitions const styleSheets = Array.from(document.styleSheets); let hasSpinnerCSS = false; try { for (const sheet of styleSheets) { if (sheet.cssRules) { for (const rule of sheet.cssRules) { const cssText = rule.cssText?.toLowerCase() || ''; if (cssText.includes('spinner') || cssText.includes('loading') || cssText.includes('@keyframes') && (cssText.includes('spin') || cssText.includes('rotate'))) { hasSpinnerCSS = true; break; } } } } } catch { // Cross-origin stylesheets will throw } // Check for spinner elements in DOM const spinnerElements = document.querySelectorAll( '.spinner, ' + '.loader, ' + '.loading, ' + '[class*="spinner"], ' + '[class*="loader"], ' + '.spinner-border, ' + '.spinner-grow' ); return { hasSpinnerCSS, spinnerCount: spinnerElements.length }; }); const hasSpinners = result.hasSpinnerCSS || result.spinnerCount > 0; return { passed: hasSpinners, message: `Spinners: CSS=${result.hasSpinnerCSS}, elements=${result.spinnerCount}` }; }, async skeletonLoadersExist(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(() => { const skeletons = document.querySelectorAll( '.skeleton, ' + '.placeholder, ' + '[class*="skeleton"], ' + '[class*="placeholder"], ' + '.loading-placeholder' ); // Check CSS for skeleton animations const styleSheets = Array.from(document.styleSheets); let hasSkeletonCSS = false; try { for (const sheet of styleSheets) { if (sheet.cssRules) { for (const rule of sheet.cssRules) { const cssText = rule.cssText?.toLowerCase() || ''; if (cssText.includes('skeleton') || cssText.includes('placeholder') || cssText.includes('shimmer') || cssText.includes('pulse')) { hasSkeletonCSS = true; break; } } } } } catch { // Cross-origin stylesheets will throw } return { hasSkeletonCSS, skeletonCount: skeletons.length }; }); if (!result.hasSkeletonCSS && result.skeletonCount === 0) { return { passed: null, skipped: true, message: 'No skeleton loaders found' }; } return { passed: true, message: `Skeleton loaders: CSS=${result.hasSkeletonCSS}, elements=${result.skeletonCount}` }; }, async buttonLoadingState(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(() => { const buttons = document.querySelectorAll('button[type="submit"], .submit-btn, .btn-primary'); // Check for loading state classes/attributes in button let hasLoadingState = false; let loadingClass = ''; for (const btn of buttons) { const classes = btn.className; const dataset = btn.dataset; if (classes.includes('loading') || dataset.loading || btn.hasAttribute('disabled')) { hasLoadingState = true; loadingClass = classes; break; } } // Check CSS for button loading styles const styleSheets = Array.from(document.styleSheets); let hasButtonLoadingCSS = false; try { for (const sheet of styleSheets) { if (sheet.cssRules) { for (const rule of sheet.cssRules) { const cssText = rule.cssText?.toLowerCase() || ''; if ((cssText.includes('button') || cssText.includes('.btn')) && (cssText.includes('loading') || cssText.includes('disabled'))) { hasButtonLoadingCSS = true; break; } } } } } catch { // Cross-origin stylesheets will throw } return { buttonCount: buttons.length, hasLoadingState, hasButtonLoadingCSS }; }); return { passed: result.hasLoadingState || result.hasButtonLoadingCSS, message: `Button loading: ${result.buttonCount} buttons, state=${result.hasLoadingState}, CSS=${result.hasButtonLoadingCSS}` }; } }; // ============================================================================ // Toast Notifications Tests // ============================================================================ const ToastNotificationsTests = { async toastContainerExists(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(() => { const toastContainer = document.querySelector( '.toast-container, ' + '.toasts, ' + '#toast-container, ' + '[class*="toast-wrapper"], ' + '.notification-container' ); // Check for toast CSS const styleSheets = Array.from(document.styleSheets); let hasToastCSS = false; try { for (const sheet of styleSheets) { if (sheet.cssRules) { for (const rule of sheet.cssRules) { const cssText = rule.cssText?.toLowerCase() || ''; if (cssText.includes('.toast') || cssText.includes('notification')) { hasToastCSS = true; break; } } } } } catch { // Cross-origin stylesheets will throw } return { hasContainer: !!toastContainer, hasToastCSS }; }); if (!result.hasContainer && !result.hasToastCSS) { return { passed: null, skipped: true, message: 'No toast notification system found' }; } return { passed: true, message: `Toast system: container=${result.hasContainer}, CSS=${result.hasToastCSS}` }; }, async flashMessagesDisplayed(page, baseUrl) { // The app shows flash/alert messages by routing showAlert(...) through // window.showSafeAlert, which renders into the #research-alert live region. // The old test queried alert selectors on a fresh / and returned passed:true // unconditionally — it never verified a message is displayed. Drive the real // display function with a marker and assert it renders a visible alert. // // showSafeAlert clears the container and appends the alert SYNCHRONOUSLY, so we // trigger and read in a single page.evaluate: that both isolates the marker from // the on-load /api/warnings check (which also renders into this same container) // and removes any async race. We drive showSafeAlert directly rather than a real // user trigger, so this is a rendering-mechanism check. await navigateTo(page, `${baseUrl}/`); await page.waitForSelector('#research-alert', { timeout: 15000 }); const MARKER = 'LDR_UI_TEST_FLASH_MARKER'; const result = await page.evaluate((marker) => { if (typeof window.showSafeAlert !== 'function') return { hasMechanism: false }; const a = document.querySelector('#research-alert'); if (!a) return { hasMechanism: true, hasContainer: false }; a.innerHTML = ''; a.style.display = 'none'; window.showSafeAlert('research-alert', marker, 'error'); // Read in the same synchronous tick — no async warning re-render can race it. const child = a.firstElementChild; return { hasMechanism: true, hasContainer: true, visible: window.getComputedStyle(a).display !== 'none', childCount: a.children.length, rendered: a.textContent.includes(marker), // role of the APPENDED alert element (createSafeAlertElement sets // role="alert"), not the container's static template role — so this // conjunct actually verifies the rendered alert, not a constant. childRole: child ? child.getAttribute('role') : null, }; }, MARKER); // Restore #research-alert to its hidden/empty default so the marker alert // doesn't leak into sibling tests that share this page. await page.evaluate(() => { const a = document.querySelector('#research-alert'); if (a) { a.innerHTML = ''; a.style.display = 'none'; } }); if (!result.hasMechanism) { return { passed: false, message: 'Alert mechanism (window.showSafeAlert) not loaded on /' }; } if (!result.hasContainer) { return { passed: false, message: '#research-alert container not found on /' }; } const passed = result.rendered && result.visible && result.childCount > 0 && result.childRole === 'alert'; return { passed, message: passed ? `Flash message rendered into #research-alert (alert role="alert", visible, ${result.childCount} child)` : `Flash message not displayed (rendered=${result.rendered}, visible=${result.visible}, children=${result.childCount}, childRole=${result.childRole})` }; } }; // ============================================================================ // Success/Error Feedback Tests // ============================================================================ const FeedbackTests = { async successFeedbackStyles(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(() => { // Check for success-related CSS classes const successElements = document.querySelectorAll( '.success, ' + '.alert-success, ' + '.text-success, ' + '.bg-success, ' + '[class*="success"]' ); // Check CSS for success colors let hasSuccessCSS = false; const styleSheets = Array.from(document.styleSheets); try { for (const sheet of styleSheets) { if (sheet.cssRules) { for (const rule of sheet.cssRules) { const cssText = rule.cssText?.toLowerCase() || ''; if (cssText.includes('success') && (cssText.includes('green') || cssText.includes('#28a745') || cssText.includes('#198754'))) { hasSuccessCSS = true; break; } } } } } catch { // Cross-origin stylesheets will throw } return { elementCount: successElements.length, hasSuccessCSS }; }); return { passed: result.hasSuccessCSS || result.elementCount > 0, message: `Success styles: elements=${result.elementCount}, CSS=${result.hasSuccessCSS}` }; }, async errorFeedbackStyles(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(() => { // Check for error-related CSS classes const errorElements = document.querySelectorAll( '.error, ' + '.alert-danger, ' + '.text-danger, ' + '.bg-danger, ' + '[class*="error"], ' + '.invalid-feedback' ); // Check CSS for error colors let hasErrorCSS = false; const styleSheets = Array.from(document.styleSheets); try { for (const sheet of styleSheets) { if (sheet.cssRules) { for (const rule of sheet.cssRules) { const cssText = rule.cssText?.toLowerCase() || ''; if ((cssText.includes('error') || cssText.includes('danger') || cssText.includes('invalid')) && (cssText.includes('red') || cssText.includes('#dc3545') || cssText.includes('#f44336'))) { hasErrorCSS = true; break; } } } } } catch { // Cross-origin stylesheets will throw } return { elementCount: errorElements.length, hasErrorCSS }; }); return { passed: result.hasErrorCSS || result.elementCount > 0, message: `Error styles: elements=${result.elementCount}, CSS=${result.hasErrorCSS}` }; }, async warningFeedbackStyles(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(() => { const warningElements = document.querySelectorAll( '.warning, ' + '.alert-warning, ' + '.text-warning, ' + '[class*="warning"]' ); let hasWarningCSS = false; const styleSheets = Array.from(document.styleSheets); try { for (const sheet of styleSheets) { if (sheet.cssRules) { for (const rule of sheet.cssRules) { const cssText = rule.cssText?.toLowerCase() || ''; if (cssText.includes('warning') && (cssText.includes('yellow') || cssText.includes('orange') || cssText.includes('#ffc107'))) { hasWarningCSS = true; break; } } } } } catch { // Cross-origin stylesheets will throw } return { elementCount: warningElements.length, hasWarningCSS }; }); return { passed: result.hasWarningCSS || result.elementCount > 0, message: `Warning styles: elements=${result.elementCount}, CSS=${result.hasWarningCSS}` }; } }; // ============================================================================ // Progress Indicators Tests // ============================================================================ const ProgressIndicatorsTests = { async progressBarExists(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); await delay(500); const result = await page.evaluate(() => { // Broader search for progress indicators const progressBars = document.querySelectorAll( '.progress, ' + '.progress-bar, ' + '[role="progressbar"], ' + 'progress, ' + '[class*="progress"], ' + '.spinner, ' + '[class*="spinner"], ' + '[class*="loading"], ' + '.loader' ); // Check CSS for progress styles let hasProgressCSS = false; const styleSheets = Array.from(document.styleSheets); try { for (const sheet of styleSheets) { if (sheet.cssRules) { for (const rule of sheet.cssRules) { const cssText = rule.cssText?.toLowerCase() || ''; if (cssText.includes('.progress') || cssText.includes('progressbar') || cssText.includes('spinner') || cssText.includes('@keyframes')) { hasProgressCSS = true; break; } } } } } catch { // Cross-origin stylesheets will throw } return { count: progressBars.length, hasProgressCSS }; }); // Skip instead of fail if no progress bars found - they may appear dynamically if (result.count === 0 && !result.hasProgressCSS) { return { passed: null, skipped: true, message: 'No progress indicators visible (may appear during active research)' }; } return { passed: true, message: `Progress bars: ${result.count} found, CSS=${result.hasProgressCSS}` }; }, async progressPageHasIndicators(page, baseUrl) { // Navigate to a progress page if available await navigateTo(page, `${baseUrl}/history`); // Look for any in-progress research const researchId = await page.evaluate(() => { const inProgress = document.querySelector('[data-status="in_progress"], .in-progress, [class*="running"]'); if (inProgress) { const link = inProgress.querySelector('a[href*="progress"]'); const match = link?.href?.match(/\/progress\/([a-zA-Z0-9-]+)/); return match ? match[1] : null; } return null; }); if (!researchId) { // Try to access any progress page await navigateTo(page, `${baseUrl}/progress/test-id`).catch(() => {}); } else { await navigateTo(page, `${baseUrl}/progress/${researchId}`); } const result = await page.evaluate(() => { const progressBar = document.querySelector('.progress, .progress-bar, [role="progressbar"]'); const statusText = document.querySelector('.status, .status-text, [class*="status"]'); const spinner = document.querySelector('.spinner, .loader, [class*="loading"]'); const percentText = document.body.textContent.match(/\d+%/); return { hasProgressBar: !!progressBar, hasStatusText: !!statusText, hasSpinner: !!spinner, hasPercent: !!percentText, statusContent: statusText?.textContent?.trim()?.substring(0, 50) }; }); const hasIndicators = result.hasProgressBar || result.hasStatusText || result.hasSpinner || result.hasPercent; return { passed: hasIndicators, message: `Progress page: bar=${result.hasProgressBar}, status="${result.statusContent}", spinner=${result.hasSpinner}` }; } }; // ============================================================================ // Hover & Active States Tests // ============================================================================ const HoverActiveStatesTests = { async buttonHoverStates(page, baseUrl) { // :hover pseudo-class is unreliable in headless Chrome — visual changes // may not apply even when hover() is called if (process.env.CI) { return { passed: null, skipped: true, message: 'Hover styles unreliable in headless Chrome' }; } await navigateTo(page, `${baseUrl}/`); const button = await page.$('button, .btn'); if (!button) { return { passed: null, skipped: true, message: 'No button to test hover state' }; } const beforeHover = await page.evaluate(() => { const btn = document.querySelector('button, .btn'); const style = window.getComputedStyle(btn); return { backgroundColor: style.backgroundColor, boxShadow: style.boxShadow, transform: style.transform }; }); await button.hover(); await delay(200); const afterHover = await page.evaluate(() => { const btn = document.querySelector('button, .btn'); const style = window.getComputedStyle(btn); return { backgroundColor: style.backgroundColor, boxShadow: style.boxShadow, transform: style.transform }; }); const hasHoverEffect = beforeHover.backgroundColor !== afterHover.backgroundColor || beforeHover.boxShadow !== afterHover.boxShadow || beforeHover.transform !== afterHover.transform; return { passed: hasHoverEffect, message: hasHoverEffect ? 'Buttons have hover state changes' : 'No visible hover effect on buttons' }; }, async linkHoverStates(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); await delay(300); const link = await page.$('a[href]'); if (!link) { return { passed: null, skipped: true, message: 'No link to test hover state' }; } try { const beforeHover = await page.evaluate(() => { const a = document.querySelector('a[href]'); if (!a) return null; const style = window.getComputedStyle(a); return { textDecoration: style.textDecoration, color: style.color, opacity: style.opacity, transform: style.transform }; }); if (!beforeHover) { return { passed: null, skipped: true, message: 'Link not accessible for hover test' }; } await link.hover(); await delay(300); const afterHover = await page.evaluate(() => { const a = document.querySelector('a[href]'); if (!a) return null; const style = window.getComputedStyle(a); return { textDecoration: style.textDecoration, color: style.color, opacity: style.opacity, transform: style.transform }; }); if (!afterHover) { return { passed: null, skipped: true, message: 'Link state changed during hover test' }; } const hasHoverEffect = beforeHover.textDecoration !== afterHover.textDecoration || beforeHover.color !== afterHover.color || beforeHover.opacity !== afterHover.opacity || beforeHover.transform !== afterHover.transform; // Skip instead of fail - hover effects may be subtle or not implemented if (!hasHoverEffect) { return { passed: null, skipped: true, message: 'No visible hover effect detected on links (may use subtle or CSS-in-JS styles)' }; } return { passed: true, message: `Links have hover effect (decoration: ${beforeHover.textDecoration} -> ${afterHover.textDecoration})` }; } catch (err) { return { passed: null, skipped: true, message: `Hover test failed: ${err.message}` }; } } }; // ============================================================================ // Animation Tests // ============================================================================ const AnimationTests = { async cssAnimationsDefined(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(() => { const styleSheets = Array.from(document.styleSheets); const animations = []; try { for (const sheet of styleSheets) { if (sheet.cssRules) { for (const rule of sheet.cssRules) { if (rule.type === CSSRule.KEYFRAMES_RULE) { animations.push(rule.name); } } } } } catch { // Cross-origin stylesheets will throw } return { count: animations.length, names: animations.slice(0, 10) }; }); return { passed: result.count > 0, message: `CSS animations: ${result.count} defined (${result.names.join(', ')})` }; }, async transitionsUsed(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(() => { const elementsWithTransitions = []; const allElements = document.querySelectorAll('*'); for (const el of allElements) { const style = window.getComputedStyle(el); if (style.transition && style.transition !== 'none' && style.transition !== 'all 0s ease 0s') { elementsWithTransitions.push(el.tagName); if (elementsWithTransitions.length >= 10) break; } } return { count: elementsWithTransitions.length, elements: elementsWithTransitions.slice(0, 5) }; }); return { passed: result.count > 0, message: `CSS transitions: ${result.count}+ elements (${result.elements.join(', ')})` }; } }; // ============================================================================ // Main Test Runner // ============================================================================ async function main() { log.section('Loading & Feedback UI Tests'); const ctx = await setupTest({ authenticate: true }); const results = new TestResults('Loading Feedback Tests'); const { page } = ctx; const { baseUrl } = ctx.config; const subTestTimeout = ctx.config.isCI ? 60000 : 30000; async function run(category, name, testFn) { try { const result = await withTimeout( testFn(page, baseUrl), subTestTimeout, `${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 { // Loading States log.section('Loading States'); await run('Loading', 'Spinners', (p, u) => LoadingStatesTests.loadingSpinnersExist(p, u)); await run('Loading', 'Skeletons', (p, u) => LoadingStatesTests.skeletonLoadersExist(p, u)); await run('Loading', 'Button States', (p, u) => LoadingStatesTests.buttonLoadingState(p, u)); // Toast Notifications log.section('Toast Notifications'); await run('Toast', 'Container', (p, u) => ToastNotificationsTests.toastContainerExists(p, u)); await run('Toast', 'Flash Messages', (p, u) => ToastNotificationsTests.flashMessagesDisplayed(p, u)); // Success/Error Feedback log.section('Feedback Styles'); await run('Feedback', 'Success Styles', (p, u) => FeedbackTests.successFeedbackStyles(p, u)); await run('Feedback', 'Error Styles', (p, u) => FeedbackTests.errorFeedbackStyles(p, u)); await run('Feedback', 'Warning Styles', (p, u) => FeedbackTests.warningFeedbackStyles(p, u)); // Progress Indicators log.section('Progress Indicators'); await run('Progress', 'Bar Exists', (p, u) => ProgressIndicatorsTests.progressBarExists(p, u)); await run('Progress', 'Page Indicators', (p, u) => ProgressIndicatorsTests.progressPageHasIndicators(p, u)); // Hover & Active States log.section('Hover States'); await run('Hover', 'Button States', (p, u) => HoverActiveStatesTests.buttonHoverStates(p, u)); await run('Hover', 'Link States', (p, u) => HoverActiveStatesTests.linkHoverStates(p, u)); // Animations log.section('Animations'); await run('Animation', 'CSS Animations', (p, u) => AnimationTests.cssAnimationsDefined(p, u)); await run('Animation', 'Transitions', (p, u) => AnimationTests.transitionsUsed(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 = { LoadingStatesTests, ToastNotificationsTests, FeedbackTests, ProgressIndicatorsTests, HoverActiveStatesTests, AnimationTests };