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
794 lines
31 KiB
JavaScript
794 lines
31 KiB
JavaScript
/**
|
|
* Layout Bug Tests for CI
|
|
*
|
|
* Comprehensive automated tests that catch mobile and desktop UI layout bugs.
|
|
* Uses geometry-based assertions that are reliable in headless CI environments.
|
|
*
|
|
* Run: node test_layout_bugs_ci.js
|
|
*
|
|
* Environment variables:
|
|
* - BASE_URL: Server URL (default: http://localhost:5000)
|
|
* - CI: Set to 'true' for CI mode (no screenshots on pass)
|
|
* - OUTPUT_FORMAT: 'json' or 'junit' (default: json)
|
|
* - HEADLESS: 'true' or 'false' (default: true)
|
|
*/
|
|
|
|
const puppeteer = require('puppeteer');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const AuthHelper = require('../auth_helper');
|
|
|
|
// Configuration
|
|
const BASE_URL = process.env.BASE_URL || 'http://127.0.0.1:5000';
|
|
const IS_CI = process.env.CI === 'true';
|
|
const OUTPUT_FORMAT = process.env.OUTPUT_FORMAT || 'json';
|
|
const HEADLESS = process.env.HEADLESS !== 'false';
|
|
|
|
// Viewports to test - covers mobile, tablet, desktop, and short heights
|
|
// Note: The app uses 768px as mobile breakpoint, so 767px is mobile, 768px is tablet
|
|
const VIEWPORTS = {
|
|
'mobile_iPhone_SE': { width: 375, height: 667, isMobile: true },
|
|
'mobile_iPhone_14': { width: 430, height: 932, isMobile: true },
|
|
'tablet_iPad': { width: 768, height: 1024, isMobile: false }, // 768px = tablet (sidebar visible)
|
|
'desktop_normal': { width: 1280, height: 800, isMobile: false },
|
|
'desktop_short': { width: 1280, height: 600, isMobile: false },
|
|
'desktop_very_short': { width: 1280, height: 500, isMobile: false },
|
|
'desktop_wide': { width: 1920, height: 1080, isMobile: false },
|
|
};
|
|
|
|
// Pages to test (authenticated)
|
|
const AUTHENTICATED_PAGES = [
|
|
{ path: '/', name: 'Research' },
|
|
{ path: '/history/', name: 'History' },
|
|
{ path: '/news/', name: 'News' },
|
|
{ path: '/settings/', name: 'Settings' },
|
|
{ path: '/metrics/', name: 'Metrics' },
|
|
];
|
|
|
|
// Pages to test (unauthenticated)
|
|
const UNAUTHENTICATED_PAGES = [
|
|
{ path: '/auth/login', name: 'Login' },
|
|
];
|
|
|
|
// Test results collector
|
|
class TestResults {
|
|
constructor() {
|
|
this.tests = [];
|
|
this.startTime = Date.now();
|
|
}
|
|
|
|
addResult(viewport, page, testName, passed, message = '', duration = 0) {
|
|
this.tests.push({
|
|
viewport,
|
|
page,
|
|
testName,
|
|
passed,
|
|
message,
|
|
duration,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
}
|
|
|
|
get summary() {
|
|
const total = this.tests.length;
|
|
const passed = this.tests.filter(t => t.passed).length;
|
|
const failed = total - passed;
|
|
return { total, passed, failed, duration: Date.now() - this.startTime };
|
|
}
|
|
|
|
toJSON() {
|
|
return {
|
|
summary: this.summary,
|
|
tests: this.tests
|
|
};
|
|
}
|
|
|
|
toJUnitXML() {
|
|
const { summary } = this;
|
|
const testsByViewport = {};
|
|
|
|
for (const test of this.tests) {
|
|
if (!testsByViewport[test.viewport]) {
|
|
testsByViewport[test.viewport] = [];
|
|
}
|
|
testsByViewport[test.viewport].push(test);
|
|
}
|
|
|
|
let xml = `<?xml version="1.0" encoding="UTF-8"?>\n`;
|
|
xml += `<testsuites name="Layout Bug Tests" tests="${summary.total}" failures="${summary.failed}" time="${(summary.duration / 1000).toFixed(2)}">\n`;
|
|
|
|
for (const [viewport, tests] of Object.entries(testsByViewport)) {
|
|
const vpPassed = tests.filter(t => t.passed).length;
|
|
const vpFailed = tests.length - vpPassed;
|
|
const vpTime = tests.reduce((sum, t) => sum + t.duration, 0) / 1000;
|
|
|
|
xml += ` <testsuite name="${viewport}" tests="${tests.length}" failures="${vpFailed}" time="${vpTime.toFixed(2)}">\n`;
|
|
|
|
for (const test of tests) {
|
|
xml += ` <testcase name="${test.page} - ${test.testName}" time="${(test.duration / 1000).toFixed(3)}"`;
|
|
if (test.passed) {
|
|
xml += ` />\n`;
|
|
} else {
|
|
xml += `>\n`;
|
|
xml += ` <failure message="${escapeXml(test.message)}">${escapeXml(test.message)}</failure>\n`;
|
|
xml += ` </testcase>\n`;
|
|
}
|
|
}
|
|
|
|
xml += ` </testsuite>\n`;
|
|
}
|
|
|
|
xml += `</testsuites>\n`;
|
|
return xml;
|
|
}
|
|
}
|
|
|
|
function escapeXml(str) {
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// Layout assertions - all geometry-based, no animation dependencies
|
|
const Assertions = {
|
|
/**
|
|
* Check for horizontal overflow (content wider than viewport)
|
|
*/
|
|
async noHorizontalOverflow(page) {
|
|
const result = await page.evaluate(() => {
|
|
const scrollWidth = document.documentElement.scrollWidth;
|
|
const innerWidth = window.innerWidth;
|
|
const overflow = scrollWidth - innerWidth;
|
|
return {
|
|
passed: overflow <= 5, // 5px tolerance for scrollbars
|
|
scrollWidth,
|
|
innerWidth,
|
|
overflow
|
|
};
|
|
});
|
|
return {
|
|
passed: result.passed,
|
|
message: result.passed
|
|
? 'No horizontal overflow'
|
|
: `Horizontal overflow: ${result.overflow}px (scrollWidth=${result.scrollWidth}, innerWidth=${result.innerWidth})`
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Check sidebar visibility matches expected state for viewport
|
|
* - Mobile (<768px): sidebar hidden
|
|
* - Tablet (768-991px): sidebar collapsed (60px)
|
|
* - Desktop (≥992px): sidebar full width (200-240px)
|
|
*/
|
|
async sidebarCorrectState(page, viewportName, viewportConfig) {
|
|
const isMobileWidth = viewportConfig.width < 768;
|
|
const isTabletWidth = viewportConfig.width >= 768 && viewportConfig.width <= 991;
|
|
|
|
// On mobile, sidebar should be hidden
|
|
// On tablet/desktop, sidebar should be visible (but may be collapsed on tablet)
|
|
const shouldBeVisible = !isMobileWidth;
|
|
|
|
const result = await page.evaluate(() => {
|
|
const sidebar = document.querySelector('.ldr-sidebar');
|
|
if (!sidebar) {
|
|
return { exists: false, isVisible: false };
|
|
}
|
|
|
|
const style = window.getComputedStyle(sidebar);
|
|
const rect = sidebar.getBoundingClientRect();
|
|
|
|
// Check various ways sidebar could be hidden
|
|
const displayNone = style.display === 'none';
|
|
const visibilityHidden = style.visibility === 'hidden';
|
|
const offScreen = rect.right <= 0;
|
|
const zeroWidth = rect.width === 0;
|
|
const transformHidden = style.transform && style.transform.includes('-100%');
|
|
|
|
const isHidden = displayNone || visibilityHidden || offScreen || zeroWidth || transformHidden;
|
|
|
|
return {
|
|
exists: true,
|
|
isVisible: !isHidden,
|
|
display: style.display,
|
|
visibility: style.visibility,
|
|
width: rect.width,
|
|
left: rect.left,
|
|
right: rect.right
|
|
};
|
|
});
|
|
|
|
if (!result.exists) {
|
|
// Sidebar might not exist on some pages (like login)
|
|
return { passed: true, message: 'Sidebar not present on page (expected for some pages)' };
|
|
}
|
|
|
|
// For tablet, sidebar should exist but may be collapsed (60px)
|
|
if (isTabletWidth) {
|
|
const isCollapsedOrVisible = result.width >= 50 || result.isVisible;
|
|
return {
|
|
passed: isCollapsedOrVisible,
|
|
message: isCollapsedOrVisible
|
|
? `Sidebar correctly ${result.width >= 100 ? 'visible' : 'collapsed'} on tablet (width=${result.width}px)`
|
|
: `Sidebar should be visible/collapsed on tablet but is hidden (width=${result.width}px)`
|
|
};
|
|
}
|
|
|
|
const stateCorrect = result.isVisible === shouldBeVisible;
|
|
const expectedState = shouldBeVisible ? 'visible' : 'hidden';
|
|
const actualState = result.isVisible ? 'visible' : 'hidden';
|
|
|
|
return {
|
|
passed: stateCorrect,
|
|
message: stateCorrect
|
|
? `Sidebar correctly ${expectedState}`
|
|
: `Sidebar should be ${expectedState} but is ${actualState} (width=${result.width}, left=${result.left})`
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Check mobile nav visibility matches expected state
|
|
* @param {Page} page - Puppeteer page
|
|
* @param {string} viewportName - Name of viewport
|
|
* @param {object} viewportConfig - Viewport configuration
|
|
* @param {boolean} isAuthenticatedPage - Whether this is an authenticated page
|
|
*/
|
|
async mobileNavCorrectState(page, viewportName, viewportConfig, isAuthenticatedPage = true) {
|
|
const isMobileWidth = viewportConfig.width < 768;
|
|
|
|
// Mobile nav should only be visible on mobile widths AND authenticated pages
|
|
// Unauthenticated pages (login, register) don't have mobile nav
|
|
const shouldBeVisible = isMobileWidth && isAuthenticatedPage;
|
|
|
|
const result = await page.evaluate(() => {
|
|
const mobileNav = document.querySelector('.ldr-mobile-bottom-nav');
|
|
if (!mobileNav) {
|
|
return { exists: false, isVisible: false };
|
|
}
|
|
|
|
const style = window.getComputedStyle(mobileNav);
|
|
const rect = mobileNav.getBoundingClientRect();
|
|
|
|
const displayNone = style.display === 'none';
|
|
const visibilityHidden = style.visibility === 'hidden';
|
|
const offScreen = rect.top >= window.innerHeight;
|
|
const zeroHeight = rect.height === 0;
|
|
|
|
const isHidden = displayNone || visibilityHidden || offScreen || zeroHeight;
|
|
|
|
return {
|
|
exists: true,
|
|
isVisible: !isHidden,
|
|
display: style.display,
|
|
visibility: style.visibility,
|
|
height: rect.height,
|
|
top: rect.top,
|
|
bottom: rect.bottom
|
|
};
|
|
});
|
|
|
|
if (!result.exists) {
|
|
if (shouldBeVisible) {
|
|
return { passed: false, message: 'Mobile nav should exist but is not found in DOM' };
|
|
}
|
|
// Not visible and not expected - that's correct
|
|
const reason = !isAuthenticatedPage ? 'unauthenticated page' : 'desktop viewport';
|
|
return { passed: true, message: `Mobile nav not present (expected for ${reason})` };
|
|
}
|
|
|
|
const stateCorrect = result.isVisible === shouldBeVisible;
|
|
const expectedState = shouldBeVisible ? 'visible' : 'hidden';
|
|
const actualState = result.isVisible ? 'visible' : 'hidden';
|
|
|
|
return {
|
|
passed: stateCorrect,
|
|
message: stateCorrect
|
|
? `Mobile nav correctly ${expectedState}`
|
|
: `Mobile nav should be ${expectedState} but is ${actualState} (height=${result.height}, top=${result.top})`
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Check that all navigation items are accessible (Settings not cut off)
|
|
* @param {Page} page - Puppeteer page
|
|
* @param {object} viewportConfig - Viewport configuration
|
|
* @param {boolean} isAuthenticatedPage - Whether this is an authenticated page
|
|
*/
|
|
async allNavItemsAccessible(page, viewportConfig, isAuthenticatedPage = true) {
|
|
const isMobileWidth = viewportConfig.width < 768;
|
|
|
|
// Unauthenticated pages don't have navigation menus
|
|
if (!isAuthenticatedPage) {
|
|
return { passed: true, message: 'Navigation check skipped for unauthenticated page' };
|
|
}
|
|
|
|
if (isMobileWidth) {
|
|
// For mobile, check that Settings items exist in the sheet menu
|
|
// Note: Sheet menu uses <button> elements with data-action attributes, not <a> links
|
|
const result = await page.evaluate(() => {
|
|
const sheetMenu = document.querySelector('.ldr-mobile-sheet-menu');
|
|
if (!sheetMenu) {
|
|
return { exists: false, settingsFound: false };
|
|
}
|
|
|
|
// Look for Settings/Configuration buttons in sheet menu by data-action
|
|
const allButtons = Array.from(sheetMenu.querySelectorAll('button[data-action], .ldr-mobile-sheet-item'));
|
|
const settingsItems = allButtons.filter(btn => {
|
|
const action = btn.getAttribute('data-action') || '';
|
|
const text = btn.textContent.toLowerCase();
|
|
return action.includes('/settings') ||
|
|
action.includes('/embedding') ||
|
|
text.includes('settings') ||
|
|
text.includes('embeddings') ||
|
|
text.includes('configuration');
|
|
});
|
|
|
|
// Also check for Settings section title
|
|
const sectionTitles = Array.from(sheetMenu.querySelectorAll('.ldr-mobile-sheet-title, [class*="sheet-title"]'));
|
|
const settingsSectionTitle = sectionTitles.find(el =>
|
|
el.textContent.toLowerCase().includes('settings')
|
|
);
|
|
|
|
return {
|
|
exists: true,
|
|
settingsFound: settingsItems.length > 0 || !!settingsSectionTitle,
|
|
settingsItemsCount: settingsItems.length,
|
|
totalItems: allButtons.length,
|
|
sheetHeight: sheetMenu.getBoundingClientRect().height,
|
|
maxHeight: window.getComputedStyle(sheetMenu).maxHeight
|
|
};
|
|
});
|
|
|
|
if (!result.exists) {
|
|
return { passed: true, message: 'Sheet menu not on page (may not be needed)' };
|
|
}
|
|
|
|
return {
|
|
passed: result.settingsFound,
|
|
message: result.settingsFound
|
|
? `Settings accessible in mobile sheet menu (${result.settingsItemsCount} items found)`
|
|
: `Settings items NOT found in mobile sheet menu (${result.totalItems} total items, height=${result.sheetHeight}px)`
|
|
};
|
|
}
|
|
// For desktop/tablet, check sidebar can show all items
|
|
const result = await page.evaluate(() => {
|
|
const sidebar = document.querySelector('.ldr-sidebar');
|
|
if (!sidebar) {
|
|
return { exists: false };
|
|
}
|
|
|
|
const nav = sidebar.querySelector('.ldr-sidebar-nav');
|
|
if (!nav) {
|
|
return { exists: true, navExists: false };
|
|
}
|
|
|
|
// Get all nav items
|
|
const items = Array.from(nav.querySelectorAll('li a'));
|
|
|
|
// Check if Settings section exists
|
|
const settingsSection = Array.from(sidebar.querySelectorAll('.ldr-sidebar-section-label'))
|
|
.find(el => el.textContent.toLowerCase().includes('settings'));
|
|
|
|
// Check if any Settings items exist
|
|
const settingsItems = items.filter(a =>
|
|
a.href.includes('settings') ||
|
|
a.href.includes('embeddings') ||
|
|
a.href.includes('configuration')
|
|
);
|
|
|
|
// Check if last item is visible (within viewport)
|
|
const sidebarRect = sidebar.getBoundingClientRect();
|
|
const viewportHeight = window.innerHeight;
|
|
|
|
// Find the bottom-most item
|
|
let lastItemBottom = 0;
|
|
items.forEach(item => {
|
|
const rect = item.getBoundingClientRect();
|
|
if (rect.bottom > lastItemBottom) {
|
|
lastItemBottom = rect.bottom;
|
|
}
|
|
});
|
|
|
|
const isScrollable = nav.scrollHeight > nav.clientHeight;
|
|
const allItemsInView = lastItemBottom <= viewportHeight;
|
|
|
|
return {
|
|
exists: true,
|
|
navExists: true,
|
|
totalItems: items.length,
|
|
settingsSectionFound: !!settingsSection,
|
|
settingsItemsCount: settingsItems.length,
|
|
sidebarBottom: sidebarRect.bottom,
|
|
lastItemBottom,
|
|
viewportHeight,
|
|
isScrollable,
|
|
allItemsInView: allItemsInView || isScrollable // Either all visible or can scroll to them
|
|
};
|
|
});
|
|
|
|
if (!result.exists) {
|
|
return { passed: true, message: 'Sidebar not on page' };
|
|
}
|
|
|
|
if (!result.navExists) {
|
|
return { passed: false, message: 'Sidebar exists but nav not found' };
|
|
}
|
|
|
|
// We need either Settings items visible OR the section to be scrollable
|
|
const hasSettingsAccess = result.settingsItemsCount > 0 || result.settingsSectionFound;
|
|
const canAccessAll = result.allItemsInView;
|
|
|
|
return {
|
|
passed: hasSettingsAccess && canAccessAll,
|
|
message: hasSettingsAccess && canAccessAll
|
|
? `All ${result.totalItems} nav items accessible (Settings section found)`
|
|
: `Nav items may be cut off: lastItemBottom=${result.lastItemBottom}px, viewportHeight=${result.viewportHeight}px, settingsFound=${hasSettingsAccess}`
|
|
};
|
|
|
|
},
|
|
|
|
/**
|
|
* Check main content is not overlapped by navigation elements
|
|
* @param {Page} page - Puppeteer page
|
|
* @param {object} viewportConfig - Viewport configuration
|
|
* @param {boolean} isAuthenticatedPage - Whether this is an authenticated page
|
|
*/
|
|
async noContentBehindNav(page, viewportConfig, isAuthenticatedPage = true) {
|
|
// Unauthenticated pages don't have the navigation overlay issue
|
|
if (!isAuthenticatedPage) {
|
|
return { passed: true, message: 'Content overlap check skipped for unauthenticated page' };
|
|
}
|
|
|
|
const isMobileWidth = viewportConfig.width < 768;
|
|
|
|
const result = await page.evaluate((isMobile) => {
|
|
// For mobile, check if the content has proper padding-bottom to account for fixed nav
|
|
// For desktop, check if content properly avoids sidebar
|
|
const mainContent = document.querySelector('main, .main-content, [role="main"], .container');
|
|
if (!mainContent) {
|
|
return { mainExists: false };
|
|
}
|
|
|
|
const mainStyle = window.getComputedStyle(mainContent);
|
|
const viewportHeight = window.innerHeight;
|
|
|
|
if (isMobile) {
|
|
const mobileNav = document.querySelector('.ldr-mobile-bottom-nav');
|
|
if (!mobileNav) {
|
|
return { mainExists: true, navExists: false };
|
|
}
|
|
const navRect = mobileNav.getBoundingClientRect();
|
|
const navStyle = window.getComputedStyle(mobileNav);
|
|
|
|
if (navStyle.display === 'none' || navRect.height === 0) {
|
|
return { mainExists: true, navExists: false };
|
|
}
|
|
|
|
// The nav is fixed at bottom, check if main content has padding to avoid it
|
|
// Content naturally scrolls behind fixed elements, so we check padding instead
|
|
const paddingBottom = parseFloat(mainStyle.paddingBottom) || 0;
|
|
const marginBottom = parseFloat(mainStyle.marginBottom) || 0;
|
|
const navHeight = navRect.height;
|
|
|
|
// Content should have at least ~50px padding/margin to clear the nav
|
|
// This ensures the last content isn't hidden behind the nav
|
|
const hasSufficientSpacing = (paddingBottom + marginBottom) >= (navHeight - 10);
|
|
|
|
return {
|
|
mainExists: true,
|
|
navExists: true,
|
|
navHeight,
|
|
paddingBottom,
|
|
marginBottom,
|
|
totalSpacing: paddingBottom + marginBottom,
|
|
passed: hasSufficientSpacing
|
|
};
|
|
}
|
|
const sidebar = document.querySelector('.ldr-sidebar');
|
|
if (!sidebar) {
|
|
return { mainExists: true, navExists: false };
|
|
}
|
|
const sidebarRect = sidebar.getBoundingClientRect();
|
|
const sidebarStyle = window.getComputedStyle(sidebar);
|
|
const mainRect = mainContent.getBoundingClientRect();
|
|
|
|
if (sidebarStyle.display === 'none' || sidebarRect.width === 0) {
|
|
return { mainExists: true, navExists: false };
|
|
}
|
|
|
|
// Check horizontal overlap
|
|
const overlap = mainRect.left < sidebarRect.right ? sidebarRect.right - mainRect.left : 0;
|
|
|
|
return {
|
|
mainExists: true,
|
|
navExists: true,
|
|
mainLeft: mainRect.left,
|
|
sidebarRight: sidebarRect.right,
|
|
overlap,
|
|
passed: overlap <= 10
|
|
};
|
|
|
|
}, isMobileWidth);
|
|
|
|
if (!result.mainExists) {
|
|
return { passed: true, message: 'Main content area not found (may be expected)' };
|
|
}
|
|
|
|
if (!result.navExists) {
|
|
return { passed: true, message: 'No navigation overlap (nav not present)' };
|
|
}
|
|
|
|
if (isMobileWidth) {
|
|
return {
|
|
passed: result.passed,
|
|
message: result.passed
|
|
? `Content has proper spacing for mobile nav (spacing=${result.totalSpacing}px, navHeight=${result.navHeight}px)`
|
|
: `Content needs more bottom padding for mobile nav (spacing=${result.totalSpacing}px < navHeight=${result.navHeight}px)`
|
|
};
|
|
}
|
|
|
|
return {
|
|
passed: result.passed,
|
|
message: result.passed
|
|
? 'No content overlap with sidebar'
|
|
: `Content overlaps sidebar by ${result.overlap}px`
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Check touch targets are adequate size on mobile (warning only)
|
|
*/
|
|
async touchTargetsAdequate(page, viewportConfig) {
|
|
if (!viewportConfig.isMobile) {
|
|
return { passed: true, message: 'Touch target check skipped for non-mobile viewport' };
|
|
}
|
|
|
|
const result = await page.evaluate(() => {
|
|
const interactiveElements = document.querySelectorAll('button, a, input, [role="button"], [onclick]');
|
|
const smallTargets = [];
|
|
const MIN_SIZE = 40; // 40px minimum for touch targets
|
|
|
|
interactiveElements.forEach((el) => {
|
|
const rect = el.getBoundingClientRect();
|
|
const style = window.getComputedStyle(el);
|
|
|
|
// Skip hidden elements
|
|
if (style.display === 'none' || style.visibility === 'hidden') {
|
|
return;
|
|
}
|
|
|
|
// Skip elements with no size
|
|
if (rect.width === 0 || rect.height === 0) {
|
|
return;
|
|
}
|
|
|
|
if (rect.width < MIN_SIZE || rect.height < MIN_SIZE) {
|
|
smallTargets.push({
|
|
tag: el.tagName.toLowerCase(),
|
|
text: el.textContent?.slice(0, 30) || '',
|
|
width: Math.round(rect.width),
|
|
height: Math.round(rect.height),
|
|
classes: el.className?.split(' ').slice(0, 3).join(' ') || ''
|
|
});
|
|
}
|
|
});
|
|
|
|
return {
|
|
total: interactiveElements.length,
|
|
smallCount: smallTargets.length,
|
|
smallTargets: smallTargets.slice(0, 5) // Limit to first 5
|
|
};
|
|
});
|
|
|
|
// This is a warning, not a failure
|
|
const passed = result.smallCount === 0;
|
|
const message = passed
|
|
? `All ${result.total} touch targets are adequate size (≥40px)`
|
|
: `Warning: ${result.smallCount} touch targets under 40px: ${result.smallTargets.map(t => `${t.tag}(${t.width}x${t.height})`).join(', ')}`;
|
|
|
|
return { passed, message, isWarning: !passed };
|
|
}
|
|
};
|
|
|
|
// Main test runner
|
|
class LayoutBugTests {
|
|
constructor() {
|
|
this.results = new TestResults();
|
|
this.browser = null;
|
|
this.page = null;
|
|
this.authHelper = null;
|
|
}
|
|
|
|
async setup() {
|
|
this.browser = await puppeteer.launch({
|
|
headless: HEADLESS ? 'new' : false,
|
|
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
|
});
|
|
this.page = await this.browser.newPage();
|
|
|
|
// Set a large viewport for authentication
|
|
await this.page.setViewport({ width: 1280, height: 800 });
|
|
|
|
// Use AuthHelper for authentication
|
|
this.authHelper = new AuthHelper(this.page, BASE_URL);
|
|
await this.authHelper.ensureAuthenticated();
|
|
|
|
console.log('Authentication complete');
|
|
}
|
|
|
|
async runAllTests() {
|
|
console.log('\n========================================');
|
|
console.log(' Layout Bug Tests for CI');
|
|
console.log('========================================\n');
|
|
console.log(`Base URL: ${BASE_URL}`);
|
|
console.log(`Viewports: ${Object.keys(VIEWPORTS).length}`);
|
|
console.log(`Pages: ${AUTHENTICATED_PAGES.length + UNAUTHENTICATED_PAGES.length}`);
|
|
console.log('');
|
|
|
|
// Test authenticated pages
|
|
for (const [viewportName, viewportConfig] of Object.entries(VIEWPORTS)) {
|
|
console.log(`\n--- Testing viewport: ${viewportName} (${viewportConfig.width}x${viewportConfig.height}) ---`);
|
|
|
|
await this.page.setViewport({
|
|
width: viewportConfig.width,
|
|
height: viewportConfig.height,
|
|
isMobile: viewportConfig.isMobile,
|
|
hasTouch: viewportConfig.isMobile
|
|
});
|
|
|
|
for (const pageInfo of AUTHENTICATED_PAGES) {
|
|
await this.testPage(viewportName, viewportConfig, pageInfo);
|
|
}
|
|
}
|
|
|
|
// Test unauthenticated pages (login page)
|
|
// Create new incognito context for unauthenticated tests
|
|
const incognitoContext = await this.browser.createBrowserContext();
|
|
const incognitoPage = await incognitoContext.newPage();
|
|
|
|
for (const [viewportName, viewportConfig] of Object.entries(VIEWPORTS)) {
|
|
await incognitoPage.setViewport({
|
|
width: viewportConfig.width,
|
|
height: viewportConfig.height,
|
|
isMobile: viewportConfig.isMobile,
|
|
hasTouch: viewportConfig.isMobile
|
|
});
|
|
|
|
for (const pageInfo of UNAUTHENTICATED_PAGES) {
|
|
// Pass false for isAuthenticatedPage
|
|
await this.testPageWithPage(incognitoPage, viewportName, viewportConfig, pageInfo, false);
|
|
}
|
|
}
|
|
|
|
await incognitoContext.close();
|
|
|
|
return this.results;
|
|
}
|
|
|
|
async testPage(viewportName, viewportConfig, pageInfo, isAuthenticatedPage = true) {
|
|
return this.testPageWithPage(this.page, viewportName, viewportConfig, pageInfo, isAuthenticatedPage);
|
|
}
|
|
|
|
async testPageWithPage(page, viewportName, viewportConfig, pageInfo, isAuthenticatedPage = true) {
|
|
const url = `${BASE_URL}${pageInfo.path}`;
|
|
|
|
try {
|
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
// Small delay to ensure CSS is fully applied
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
} catch (error) {
|
|
this.results.addResult(viewportName, pageInfo.name, 'Page Load', false, `Failed to load: ${error.message}`);
|
|
return;
|
|
}
|
|
|
|
// Run all assertions - pass isAuthenticatedPage to relevant checks
|
|
const assertions = [
|
|
{ name: 'No Horizontal Overflow', fn: () => Assertions.noHorizontalOverflow(page) },
|
|
{ name: 'Sidebar State', fn: () => Assertions.sidebarCorrectState(page, viewportName, viewportConfig) },
|
|
{ name: 'Mobile Nav State', fn: () => Assertions.mobileNavCorrectState(page, viewportName, viewportConfig, isAuthenticatedPage) },
|
|
{ name: 'All Nav Items Accessible', fn: () => Assertions.allNavItemsAccessible(page, viewportConfig, isAuthenticatedPage) },
|
|
{ name: 'No Content Behind Nav', fn: () => Assertions.noContentBehindNav(page, viewportConfig, isAuthenticatedPage) },
|
|
{ name: 'Touch Targets', fn: () => Assertions.touchTargetsAdequate(page, viewportConfig) },
|
|
];
|
|
|
|
for (const assertion of assertions) {
|
|
const startTime = Date.now();
|
|
try {
|
|
const result = await assertion.fn();
|
|
const duration = Date.now() - startTime;
|
|
|
|
const icon = result.passed ? '✓' : (result.isWarning ? '⚠' : '✗');
|
|
console.log(` ${icon} ${pageInfo.name} - ${assertion.name}: ${result.message}`);
|
|
|
|
// Treat warnings as passes for CI
|
|
this.results.addResult(
|
|
viewportName,
|
|
pageInfo.name,
|
|
assertion.name,
|
|
result.passed || result.isWarning,
|
|
result.message,
|
|
duration
|
|
);
|
|
} catch (error) {
|
|
const duration = Date.now() - startTime;
|
|
console.log(` ✗ ${pageInfo.name} - ${assertion.name}: Error - ${error.message}`);
|
|
this.results.addResult(viewportName, pageInfo.name, assertion.name, false, `Error: ${error.message}`, duration);
|
|
}
|
|
}
|
|
}
|
|
|
|
async cleanup() {
|
|
if (this.browser) {
|
|
await this.browser.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Output helpers
|
|
function writeResults(results) {
|
|
const outputDir = path.join(__dirname, 'test-results');
|
|
if (!fs.existsSync(outputDir)) {
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
}
|
|
|
|
// Always write JSON
|
|
const jsonPath = path.join(outputDir, 'layout-tests.json');
|
|
fs.writeFileSync(jsonPath, JSON.stringify(results.toJSON(), null, 2));
|
|
console.log(`\nJSON results: ${jsonPath}`);
|
|
|
|
// Write JUnit XML if requested
|
|
if (OUTPUT_FORMAT === 'junit' || IS_CI) {
|
|
const xmlPath = path.join(outputDir, 'layout-tests.xml');
|
|
fs.writeFileSync(xmlPath, results.toJUnitXML());
|
|
console.log(`JUnit XML: ${xmlPath}`);
|
|
}
|
|
}
|
|
|
|
function printSummary(results) {
|
|
const { summary } = results;
|
|
|
|
console.log('\n========================================');
|
|
console.log(' Test Summary');
|
|
console.log('========================================');
|
|
console.log(` Total: ${summary.total}`);
|
|
console.log(` Passed: ${summary.passed} (${((summary.passed / summary.total) * 100).toFixed(1)}%)`);
|
|
console.log(` Failed: ${summary.failed}`);
|
|
console.log(` Time: ${(summary.duration / 1000).toFixed(2)}s`);
|
|
console.log('========================================\n');
|
|
|
|
if (summary.failed > 0) {
|
|
console.log('Failed Tests:');
|
|
for (const test of results.tests.filter(t => !t.passed)) {
|
|
console.log(` ✗ [${test.viewport}] ${test.page} - ${test.testName}`);
|
|
console.log(` ${test.message}`);
|
|
}
|
|
console.log('');
|
|
}
|
|
}
|
|
|
|
// Main execution
|
|
async function main() {
|
|
const tester = new LayoutBugTests();
|
|
|
|
try {
|
|
await tester.setup();
|
|
const results = await tester.runAllTests();
|
|
|
|
printSummary(results);
|
|
writeResults(results);
|
|
|
|
// Exit with error code if tests failed
|
|
const exitCode = results.summary.failed > 0 ? 1 : 0;
|
|
process.exit(exitCode);
|
|
} catch (error) {
|
|
console.error('Fatal error:', error);
|
|
process.exit(1);
|
|
} finally {
|
|
await tester.cleanup();
|
|
}
|
|
}
|
|
|
|
main();
|