Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// 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();