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
809 lines
30 KiB
JavaScript
809 lines
30 KiB
JavaScript
#!/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 };
|