Files
learningcircuit--local-deep…/tests/ui_tests/mobile/test_mobile_navigation_ci.js
T
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

601 lines
22 KiB
JavaScript

#!/usr/bin/env node
/**
* Mobile Navigation CI/CD Test Suite
* Programmatic tests with assertions for automated testing
*/
const puppeteer = require('puppeteer');
const path = require('path');
const fs = require('fs').promises;
const { getPuppeteerLaunchOptions } = require('../puppeteer_config');
// Test configuration
const TEST_CONFIG = {
baseUrl: process.env.TEST_BASE_URL || 'http://127.0.0.1:5000',
headless: process.env.HEADLESS !== 'false',
timeout: 30000,
screenshotOnFailure: process.env.CI ? false : (process.env.SCREENSHOT_ON_FAILURE !== 'false'), // Disable screenshots in CI
outputFormat: process.env.OUTPUT_FORMAT || 'json', // json, junit, console
};
// Test devices
const DEVICES = {
'iPhone_14_Pro_Max': {
width: 430,
height: 932,
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
},
'iPhone_SE': {
width: 375,
height: 667,
deviceScaleFactor: 2,
isMobile: true,
hasTouch: true
},
'iPad_Mini': {
width: 768,
height: 1024,
deviceScaleFactor: 2,
isMobile: true,
hasTouch: true
},
'Desktop': {
width: 1920,
height: 1080,
deviceScaleFactor: 1,
isMobile: false,
hasTouch: false
}
};
// Test results storage
class TestResults {
constructor() {
this.startTime = new Date();
this.tests = [];
this.summary = {
total: 0,
passed: 0,
failed: 0,
skipped: 0
};
}
addTest(test) {
this.tests.push(test);
this.summary.total++;
this.summary[test.status]++;
}
getJUnit() {
const duration = (new Date() - this.startTime) / 1000;
let xml = `<?xml version="1.0" encoding="UTF-8"?>\n`;
xml += `<testsuites name="Mobile Navigation Tests" tests="${this.summary.total}" failures="${this.summary.failed}" time="${duration}">\n`;
xml += ` <testsuite name="Mobile Navigation" tests="${this.summary.total}" failures="${this.summary.failed}" time="${duration}">\n`;
for (const test of this.tests) {
xml += ` <testcase classname="${test.suite}" name="${test.name}" time="${test.duration || 0}">\n`;
if (test.status === 'failed') {
xml += ` <failure message="${this.escapeXml(test.error || 'Test failed')}">\n`;
xml += ` ${this.escapeXml(test.details || '')}\n`;
xml += ` </failure>\n`;
}
xml += ` </testcase>\n`;
}
xml += ` </testsuite>\n`;
xml += `</testsuites>`;
return xml;
}
escapeXml(str) {
return str.replace(/[<>&'"]/g, (c) => {
switch (c) {
case '<': return '&lt;';
case '>': return '&gt;';
case '&': return '&amp;';
case '\'': return '&apos;';
case '"': return '&quot;';
default: return c;
}
});
}
getJSON() {
return {
startTime: this.startTime,
endTime: new Date(),
duration: (new Date() - this.startTime) / 1000,
summary: this.summary,
tests: this.tests
};
}
}
// Test assertions
class MobileNavAssertions {
static async assertMobileNavExists(page, device) {
// Wait for mobile navigation to be dynamically created
try {
await page.waitForSelector('.ldr-mobile-bottom-nav', {
timeout: 5000,
visible: false // Element may exist but not be visible initially
});
} catch {
throw new Error(`Mobile navigation not found on ${device} after 5s wait`);
}
return true;
}
static async assertMobileNavVisible(page, device, shouldBeVisible) {
const isVisible = await page.evaluate(() => {
const nav = document.querySelector('.ldr-mobile-bottom-nav');
if (!nav) return false;
const style = window.getComputedStyle(nav);
return style.display !== 'none' && nav.classList.contains('visible');
});
if (isVisible !== shouldBeVisible) {
throw new Error(`Mobile nav visibility mismatch on ${device}. Expected: ${shouldBeVisible}, Got: ${isVisible}`);
}
return true;
}
static async assertSidebarHidden(page, device, shouldBeHidden) {
const sidebarInfo = await page.evaluate(() => {
const sidebar = document.querySelector('.ldr-sidebar, aside.ldr-sidebar');
if (!sidebar) return { exists: false, isHidden: true };
const style = window.getComputedStyle(sidebar);
const rect = sidebar.getBoundingClientRect();
return {
exists: true,
isHidden: style.display === 'none' ||
style.visibility === 'hidden' ||
style.transform.includes('translateX(-100%)') ||
style.transform.includes('translateX(-') ||
rect.left < -100,
display: style.display,
visibility: style.visibility,
transform: style.transform,
position: `${rect.left},${rect.top}`
};
});
if (!sidebarInfo.exists) {
console.log(` ✓ Sidebar doesn't exist in DOM (good for mobile)`);
return true;
}
if (sidebarInfo.isHidden !== shouldBeHidden) {
const details = `Display: ${sidebarInfo.display}, Visibility: ${sidebarInfo.visibility}, Transform: ${sidebarInfo.transform}, Position: ${sidebarInfo.position}`;
throw new Error(`Sidebar visibility mismatch on ${device}. Expected hidden: ${shouldBeHidden}, Got: ${!sidebarInfo.isHidden}\nDetails: ${details}`);
}
return true;
}
static async assertTabCount(page, expectedCount) {
// Wait for tabs to be created
await page.waitForSelector('.ldr-mobile-nav-tab', {
timeout: 5000
});
const tabCount = await page.evaluate(() => {
const tabs = document.querySelectorAll('.ldr-mobile-nav-tab');
return tabs.length;
});
if (tabCount !== expectedCount) {
throw new Error(`Tab count mismatch. Expected: ${expectedCount}, Got: ${tabCount}`);
}
return true;
}
static async assertActiveTab(page, expectedTab) {
const activeTab = await page.evaluate(() => {
const active = document.querySelector('.ldr-mobile-nav-tab.active');
return active ? active.dataset.tabId : null;
});
if (activeTab !== expectedTab) {
throw new Error(`Active tab mismatch. Expected: ${expectedTab}, Got: ${activeTab}`);
}
return true;
}
static async assertSheetMenuToggle(page) {
// Skip this test in CI - sheet menu animations are flaky in headless
if (process.env.CI) {
console.log(' ⏭️ Skipping sheet menu test in CI (animation timing issues)');
return true;
}
// Click More button
await page.click('.ldr-mobile-nav-tab[data-tab-id="more"]');
await new Promise(resolve => setTimeout(resolve, 500));
// Check if sheet is open
const sheetOpen = await page.evaluate(() => {
const sheet = document.querySelector('.ldr-mobile-sheet-menu');
return sheet && sheet.classList.contains('active');
});
if (!sheetOpen) {
throw new Error('Sheet menu failed to open');
}
// Close sheet
await page.click('.ldr-mobile-sheet-overlay');
await new Promise(resolve => setTimeout(resolve, 500));
const sheetClosed = await page.evaluate(() => {
const sheet = document.querySelector('.ldr-mobile-sheet-menu');
return sheet && !sheet.classList.contains('active');
});
if (!sheetClosed) {
throw new Error('Sheet menu failed to close');
}
return true;
}
static async assertNoOverlap(page) {
const hasOverlap = await page.evaluate(() => {
const searchInput = document.querySelector('input[type="text"], input[type="search"], textarea');
const sidebar = document.querySelector('.sidebar');
if (!searchInput || !sidebar) return false;
const searchRect = searchInput.getBoundingClientRect();
const sidebarRect = sidebar.getBoundingClientRect();
// Check if sidebar overlaps search input
return !(searchRect.right < sidebarRect.left ||
searchRect.left > sidebarRect.right ||
searchRect.bottom < sidebarRect.top ||
searchRect.top > sidebarRect.bottom);
});
if (hasOverlap) {
throw new Error('Sidebar overlaps with input elements (Issue #667)');
}
return true;
}
}
// Main test runner
class MobileNavTestRunner {
constructor() {
this.results = new TestResults();
this.browser = null;
}
async setup() {
// Check server availability
try {
const response = await fetch(TEST_CONFIG.baseUrl);
if (!response.ok) {
throw new Error(`Server not responding at ${TEST_CONFIG.baseUrl}`);
}
} catch (error) {
console.error(`❌ Server check failed: ${error.message}`);
process.exit(1);
}
this.browser = await puppeteer.launch(getPuppeteerLaunchOptions({
headless: TEST_CONFIG.headless
}));
}
async teardown() {
if (this.browser) {
await this.browser.close();
}
}
async runTest(testName, testFn, context = {}) {
const startTime = Date.now();
const test = {
name: testName,
suite: context.suite || 'MobileNavigation',
device: context.device,
status: 'passed',
error: null,
details: null,
duration: 0
};
try {
await Promise.race([
testFn(),
new Promise((_, reject) => setTimeout(() => reject(new Error(`Test "${testName}" timed out after 30s`)), 30000))
]);
if (process.env.VERBOSE) {
console.log(` ✅ ${testName}`);
}
} catch (error) {
test.status = 'failed';
test.error = error.message;
test.details = error.stack;
if (TEST_CONFIG.screenshotOnFailure && context.page) {
try {
const screenshotPath = `./test-failures/${context.device}_${testName.replace(/\s+/g, '_')}.png`;
await fs.mkdir('./test-failures', { recursive: true });
// Add visual debugging based on test type
if (testName.includes('sidebar') || testName.includes('overlap')) {
await context.page.evaluate(() => {
// Highlight sidebar in red if it exists
const sidebar = document.querySelector('.ldr-sidebar, aside.ldr-sidebar');
if (sidebar) {
sidebar.style.outline = '3px solid red';
sidebar.style.outlineOffset = '2px';
}
// Highlight mobile nav in green
const mobileNav = document.querySelector('.ldr-mobile-bottom-nav');
if (mobileNav) {
mobileNav.style.outline = '3px solid lime';
}
// Highlight any overlapping elements
const inputs = document.querySelectorAll('input, textarea, select');
inputs.forEach(el => {
el.style.outline = '2px dashed blue';
});
});
}
if (testName.includes('tab') || testName.includes('navigation')) {
await context.page.evaluate(() => {
// Highlight active tab
const activeTab = document.querySelector('.ldr-mobile-nav-tab.active');
if (activeTab) {
activeTab.style.backgroundColor = 'yellow';
activeTab.style.color = 'black';
}
// Number all tabs
const tabs = document.querySelectorAll('.ldr-mobile-nav-tab');
tabs.forEach((tab, i) => {
const badge = document.createElement('span');
badge.textContent = `${i + 1}`;
badge.style.cssText = 'position:absolute;top:0;right:0;background:red;color:white;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:12px;';
tab.style.position = 'relative';
tab.appendChild(badge);
});
});
}
// Add timeout to screenshot to prevent hanging
await context.page.screenshot({
path: screenshotPath,
fullPage: true,
timeout: 5000 // 5 second timeout for screenshot
});
test.screenshot = screenshotPath;
console.log(` 📸 Screenshot saved: ${screenshotPath}`);
} catch (screenshotError) {
console.error(`Warning: Failed to capture screenshot: ${screenshotError.message}`);
// Don't fail the test because of screenshot failure
}
}
console.error(` ❌ ${testName}: ${error.message}`);
} finally {
test.duration = (Date.now() - startTime) / 1000;
this.results.addTest(test);
}
}
async testDevice(deviceName, viewport) {
console.log(`\nTesting ${deviceName}...`);
const page = await this.browser.newPage();
await page.setViewport(viewport);
const context = { device: deviceName, page, suite: `MobileNav.${deviceName}` };
try {
// First authenticate to test on authenticated pages
const AuthHelper = require('../auth_helper');
const authHelper = new AuthHelper(page, TEST_CONFIG.baseUrl);
await authHelper.ensureAuthenticatedWithTimeout();
// Navigate to home page after authentication
await page.goto(TEST_CONFIG.baseUrl, {
waitUntil: 'domcontentloaded',
timeout: TEST_CONFIG.timeout
});
// Ensure mobile navigation script has loaded
await page.waitForFunction(() => window.mobileNav !== undefined, {
timeout: 5000
});
// Test 1: Mobile nav visibility based on viewport
const shouldShowMobileNav = viewport.width <= 767;
await this.runTest(
`Mobile nav visibility (${viewport.width}px)`,
async () => {
if (shouldShowMobileNav) {
await MobileNavAssertions.assertMobileNavExists(page, deviceName);
await MobileNavAssertions.assertMobileNavVisible(page, deviceName, true);
} else {
await MobileNavAssertions.assertMobileNavVisible(page, deviceName, false);
}
},
context
);
// Test 2: Sidebar hidden on mobile
await this.runTest(
'Sidebar hidden on mobile',
async () => {
// Skip iPad Mini sidebar test in CI - rendering inconsistencies
if (process.env.CI && deviceName === 'iPad_Mini') {
console.log(' ⏭️ Skipping iPad Mini sidebar test in CI (rendering inconsistencies)');
return;
}
const shouldHideSidebar = viewport.width <= 767;
await MobileNavAssertions.assertSidebarHidden(page, deviceName, shouldHideSidebar);
},
context
);
// Test 3: No overlap with input elements (Issue #667)
await this.runTest(
'No sidebar overlap with inputs',
async () => {
await MobileNavAssertions.assertNoOverlap(page);
},
context
);
// Test 3b: Also test login page for overlap (Issue #667)
await this.runTest(
'No sidebar overlap on login page',
async () => {
// Navigate to login page
await page.goto(`${TEST_CONFIG.baseUrl}/auth/login`, {
waitUntil: 'domcontentloaded',
timeout: TEST_CONFIG.timeout
});
await MobileNavAssertions.assertNoOverlap(page);
// Navigate back to home
await page.goto(TEST_CONFIG.baseUrl, {
waitUntil: 'domcontentloaded',
timeout: TEST_CONFIG.timeout
});
},
context
);
// Mobile-specific tests
if (shouldShowMobileNav) {
// Test 4: Correct number of tabs
await this.runTest(
'Correct tab count',
async () => {
await MobileNavAssertions.assertTabCount(page, 5);
},
context
);
// Test 5: Sheet menu toggle
await this.runTest(
'Sheet menu toggle',
async () => {
await MobileNavAssertions.assertSheetMenuToggle(page);
},
context
);
// Test 6: Navigation between pages
await this.runTest(
'Tab navigation',
async () => {
// Skip navigation test in CI - timing issues with navigation
if (process.env.CI) {
console.log(' ⏭️ Skipping tab navigation test in CI (navigation timing issues)');
return;
}
// Click History tab
const [_response] = await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 10000 }),
page.click('.ldr-mobile-nav-tab[data-tab-id="history"]')
]);
const url = page.url();
if (!url.includes('/history')) {
throw new Error(`Navigation failed. Expected /history, got ${url}`);
}
// Check active tab
await MobileNavAssertions.assertActiveTab(page, 'history');
},
context
);
}
} finally {
await page.close();
}
}
async run() {
console.log('🚀 Mobile Navigation CI/CD Test Suite');
console.log('=====================================');
console.log(`Base URL: ${TEST_CONFIG.baseUrl}`);
console.log(`Output Format: ${TEST_CONFIG.outputFormat}`);
console.log('');
await this.setup();
try {
// Test each device
for (const [deviceName, viewport] of Object.entries(DEVICES)) {
await this.testDevice(deviceName, viewport);
}
} finally {
await this.teardown();
}
// Output results
await this.outputResults();
// Exit with appropriate code
process.exit(this.results.summary.failed > 0 ? 1 : 0);
}
async outputResults() {
console.log('\n=====================================');
console.log('TEST RESULTS');
console.log('=====================================');
console.log(`Total: ${this.results.summary.total}`);
console.log(`Passed: ${this.results.summary.passed}`);
console.log(`Failed: ${this.results.summary.failed}`);
console.log(`Duration: ${((new Date() - this.results.startTime) / 1000).toFixed(2)}s`);
// Save results based on output format
if (TEST_CONFIG.outputFormat === 'junit') {
const junitPath = './test-results.xml';
await fs.writeFile(junitPath, this.results.getJUnit());
console.log(`\nJUnit results saved to: ${junitPath}`);
} else if (TEST_CONFIG.outputFormat === 'json') {
const jsonPath = './test-results.json';
await fs.writeFile(jsonPath, JSON.stringify(this.results.getJSON(), null, 2));
console.log(`\nJSON results saved to: ${jsonPath}`);
}
// Always output to console if there are failures
if (this.results.summary.failed > 0) {
console.log('\n❌ FAILED TESTS:');
for (const test of this.results.tests) {
if (test.status === 'failed') {
console.log(` - ${test.suite}.${test.name}: ${test.error}`);
if (test.screenshot) {
console.log(` Screenshot: ${test.screenshot}`);
}
}
}
}
}
}
// Run tests if executed directly
if (require.main === module) {
const runner = new MobileNavTestRunner();
runner.run().catch(error => {
console.error('Test runner failed:', error);
process.exit(1);
});
}
module.exports = { MobileNavTestRunner, MobileNavAssertions, TestResults };