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

479 lines
20 KiB
JavaScript

#!/usr/bin/env node
/**
* Mobile Interactions UI Tests
*
* Tests for mobile-specific interactions: modals, navigation, forms on touch devices.
*
* Run: node test_mobile_interactions_ci.js
*/
const { setupTest, teardownTest, TestResults, log, navigateTo, withTimeout } = require('./test_lib');
// ============================================================================
// Mobile Modal Tests
// ============================================================================
const MobileModalTests = {
async createFormFitsMobile(page, baseUrl) {
// The original test (modalOpensOnMobile) navigated to a 404
// /collections route expecting a modal-opens-on-click flow. The real
// app does page navigation for the create flow, so we test the
// mobile-friendliness of /library/collections/create directly:
// the form must fit the 375px viewport and the required name input
// must be a touch-sized hit target (>=44px tall is the Apple HIG
// minimum; >=30px keeps us tolerant of theme variations).
await page.setViewport({ width: 375, height: 667, isMobile: true, hasTouch: true });
await navigateTo(page, `${baseUrl}/library/collections/create`);
const result = await page.evaluate(() => {
const form = document.querySelector('form:not(#logout-form)');
const name = document.querySelector('#collection-name, input[name="name"]');
if (!form || !name) return { hasForm: false };
const formRect = form.getBoundingClientRect();
const nameRect = name.getBoundingClientRect();
return {
hasForm: true,
formWidth: formRect.width,
screenWidth: window.innerWidth,
formFitsScreen: formRect.width <= window.innerWidth,
nameVisible: nameRect.width > 0 && nameRect.height > 0,
nameHeight: nameRect.height,
nameTouchSized: nameRect.height >= 30,
};
});
if (!result.hasForm) {
return { passed: false, message: 'Create form not found on /library/collections/create' };
}
const passed = result.formFitsScreen && result.nameVisible && result.nameTouchSized;
return {
passed,
message: passed
? `Create form mobile-fit (${result.formWidth}px form, ${result.nameHeight}px name input)`
: `Mobile fit failed (formWidth=${result.formWidth}, screen=${result.screenWidth}, nameHeight=${result.nameHeight})`
};
},
async modalScrollableContent(page, baseUrl) {
// /settings/ renders NO modal, so the old test always skipped
// ("No modal content found"). The real app renders a Bootstrap
// delete-confirmation modal (#deleteConfirmModal, included by
// pages/library.html). We open it on /library with enough detail
// rows to overflow a 375x667 phone, then assert it really opens and
// its tall content stays reachable via the modal's scroll container.
await page.setViewport({ width: 375, height: 667, isMobile: true, hasTouch: true });
await navigateTo(page, `${baseUrl}/library/`);
// Page-specific anchor: fail (not skip) if login/error loaded instead.
await page.waitForSelector('.ldr-library-container', { timeout: 10000 });
await page.waitForSelector('#deleteConfirmModal .modal-body', { timeout: 10000 });
// Open the modal with long content (forces vertical overflow).
const opened = await page.evaluate(() => {
if (!window.DeleteConfirmation || !window.DeleteConfirmation.show) {
return { api: false };
}
window.DeleteConfirmation.show({
action: 'deleteDocument',
details: Array.from({ length: 60 }, (_, i) => `Document row ${i}`),
});
return { api: true };
});
if (!opened.api) {
return { passed: false, message: 'window.DeleteConfirmation.show API not available on /library' };
}
// Wait for Bootstrap to apply the .show / display:block state.
await page.waitForFunction(() => {
const m = document.getElementById('deleteConfirmModal');
return m && m.classList.contains('show') && window.getComputedStyle(m).display !== 'none';
}, { timeout: 5000 });
const result = await page.evaluate(() => {
const modal = document.getElementById('deleteConfirmModal');
const content = modal.querySelector('.modal-content');
const body = modal.querySelector('.modal-body');
const contentStyle = window.getComputedStyle(content);
const bodyRect = body.getBoundingClientRect();
// On mobile (<=767px) the real scroll container is .modal-content,
// which mobile-responsive.css caps with
// `max-height: calc(100vh - 180px); overflow-y: auto`. Reading
// overflow on the OUTER .modal would be meaningless here — Bootstrap
// base sets `.modal{overflow:hidden auto}` unconditionally, so that
// check can never fail. Assert the container is actually scrollable
// AND that the injected 60-row content overflows its capped height,
// so a regression that removes the mobile cap (content would grow to
// fit with no overflow) fails this test.
return {
containerOverflowY: contentStyle.overflowY,
containerScrolls: contentStyle.overflowY === 'auto' || contentStyle.overflowY === 'scroll',
// offsetParent !== null rejects a modal body hidden via display:none
// on any ancestor (e.g. a backdrop-only / never-shown modal) that a
// bare width/height check would still accept.
bodyVisible: bodyRect.width > 0 && bodyRect.height > 0 && body.offsetParent !== null,
contentOverflows: content.scrollHeight > content.clientHeight,
contentScrollHeight: content.scrollHeight,
contentClientHeight: content.clientHeight,
viewportHeight: window.innerHeight,
};
});
const passed = result.containerScrolls && result.bodyVisible && result.contentOverflows;
return {
passed,
message: passed
? `Delete modal content scrollable on mobile (overflow=${result.containerOverflowY}, content ${result.contentScrollHeight}px > container ${result.contentClientHeight}px on ${result.viewportHeight}px screen)`
: `Modal scroll check failed (overflowY=${result.containerOverflowY}, bodyVisible=${result.bodyVisible}, contentOverflows=${result.contentOverflows}, contentScroll=${result.contentScrollHeight}, contentClient=${result.contentClientHeight})`
};
}
};
// ============================================================================
// Mobile Navigation Tests
// ============================================================================
const MobileNavTests = {
async mobileMenuOpens(page, baseUrl) {
await page.setViewport({ width: 375, height: 667, isMobile: true, hasTouch: true });
await navigateTo(page, `${baseUrl}/`);
const result = await page.evaluate(() => {
const hamburger = document.querySelector(
'.hamburger, ' +
'.mobile-menu-toggle, ' +
'.navbar-toggler, ' +
'button[aria-label*="menu"], ' +
'.menu-toggle, ' +
'[class*="hamburger"]'
);
if (!hamburger) return { hasHamburger: false };
hamburger.click();
return new Promise(resolve => {
setTimeout(() => {
const menu = document.querySelector(
'.mobile-menu, ' +
'.navbar-collapse.show, ' +
'.nav-menu.open, ' +
'.sidebar.open, ' +
'[class*="mobile-nav"]'
);
const isVisible = menu && (
menu.style.display !== 'none' &&
!menu.classList.contains('collapsed')
);
resolve({
hasHamburger: true,
menuOpened: !!menu,
isVisible
});
}, 300);
});
});
if (!result.hasHamburger) {
// Check if using bottom nav instead
const hasBottomNav = await page.evaluate(() => {
return !!document.querySelector('.bottom-nav, .mobile-nav-tabs, .tab-bar');
});
if (hasBottomNav) {
return { passed: true, message: 'Using bottom navigation instead of hamburger menu' };
}
return { passed: null, skipped: true, message: 'No hamburger menu found (may use different nav pattern)' };
}
return {
passed: result.menuOpened,
message: result.menuOpened
? 'Mobile menu opens on tap'
: 'Mobile menu did not open'
};
},
async mobileMenuCloses(page, baseUrl) {
// The old test looked for .hamburger / .navbar-toggler / .nav-link /
// .navbar-collapse — none of which exist in this app, so it always
// skipped ("No menu links found"). The real mobile menu is built by
// static/js/mobile-navigation.js: a bottom tab bar
// (.ldr-mobile-bottom-nav) whose "More" tab ([data-tab-id="more"])
// opens a sheet (.ldr-mobile-sheet-menu, gains .active). Selecting a
// sheet item (.ldr-mobile-sheet-item) calls closeSheet() before
// navigating, so the sheet must collapse on selection.
await page.setViewport({ width: 375, height: 667, isMobile: true, hasTouch: true });
await navigateTo(page, `${baseUrl}/`);
// Mobile nav is created on DOMContentLoaded; wait for the real menu.
await page.waitForSelector('.ldr-mobile-bottom-nav [data-tab-id="more"]', { timeout: 10000 });
await page.waitForSelector('.ldr-mobile-sheet-menu', { timeout: 10000 });
// Open the sheet via the "More" tab and confirm it actually opened.
await page.evaluate(() => {
document.querySelector('.ldr-mobile-bottom-nav [data-tab-id="more"]').click();
});
await page.waitForFunction(
() => document.querySelector('.ldr-mobile-sheet-menu')?.classList.contains('active') === true,
{ timeout: 5000 }
);
// Select a sheet item; handleSheetItem() runs closeSheet() synchronously
// (removing .active + setting aria-hidden) before navigating away.
const result = await page.evaluate(() => {
const sheet = document.querySelector('.ldr-mobile-sheet-menu');
const item = sheet.querySelector('.ldr-mobile-sheet-item[data-item-id="metrics"]') ||
sheet.querySelector('.ldr-mobile-sheet-item');
if (!item) return { hasLinks: false };
const wasOpen = sheet.classList.contains('active');
item.click();
return {
hasLinks: true,
wasOpen,
menuClosed: !sheet.classList.contains('active') &&
sheet.getAttribute('aria-hidden') === 'true',
ariaHidden: sheet.getAttribute('aria-hidden'),
};
});
if (!result.hasLinks) {
return { passed: false, message: 'Mobile sheet rendered no menu items (.ldr-mobile-sheet-item)' };
}
const passed = result.wasOpen && result.menuClosed;
return {
passed,
message: passed
? 'Mobile sheet menu collapses after selecting an item'
: `Sheet did not collapse on selection (wasOpen=${result.wasOpen}, aria-hidden=${result.ariaHidden})`
};
},
async bottomNavTabsWork(page, baseUrl) {
await page.setViewport({ width: 375, height: 667, isMobile: true, hasTouch: true });
await navigateTo(page, `${baseUrl}/`);
const result = await page.evaluate(() => {
const bottomNav = document.querySelector('.bottom-nav, .mobile-nav-tabs, .tab-bar, .mobile-tabs');
if (!bottomNav) return { hasBottomNav: false };
const tabs = bottomNav.querySelectorAll('a, button, .tab');
if (tabs.length === 0) return { hasBottomNav: true, hasTabs: false };
const tabInfo = Array.from(tabs).map(tab => ({
text: tab.textContent?.trim(),
href: tab.href || tab.dataset?.href
}));
// Click the second tab if available
if (tabs.length > 1) {
tabs[1].click();
}
return {
hasBottomNav: true,
hasTabs: true,
tabCount: tabs.length,
tabs: tabInfo.slice(0, 5)
};
});
if (!result.hasBottomNav) {
return { passed: null, skipped: true, message: 'No bottom navigation found' };
}
if (!result.hasTabs) {
return { passed: false, message: 'Bottom nav has no tabs' };
}
return {
passed: true,
message: `Bottom nav has ${result.tabCount} tabs: ${result.tabs.map(t => t.text).join(', ')}`
};
},
async swipeGesturesWork(page, baseUrl) {
await page.setViewport({ width: 375, height: 667, isMobile: true, hasTouch: true });
await navigateTo(page, `${baseUrl}/`);
// Check if there are swipeable elements
const result = await page.evaluate(() => {
const swipeableElements = document.querySelectorAll(
'.swipeable, ' +
'[data-swipe], ' +
'.carousel, ' +
'.slider, ' +
'[class*="swipe"]'
);
const hasTouchSupport = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
return {
hasSwipeableElements: swipeableElements.length > 0,
elementCount: swipeableElements.length,
hasTouchSupport
};
});
if (!result.hasSwipeableElements) {
return { passed: null, skipped: true, message: 'No swipeable elements found' };
}
return {
passed: true,
message: `${result.elementCount} swipeable elements found`
};
}
};
// ============================================================================
// Mobile Form Tests
// ============================================================================
const MobileFormTests = {
async mobileKeyboardDoesntBreakLayout(page, baseUrl) {
await page.setViewport({ width: 375, height: 667, isMobile: true, hasTouch: true });
await navigateTo(page, `${baseUrl}/`);
const result = await page.evaluate(() => {
const input = document.querySelector('input[type="text"], input[type="search"], textarea');
if (!input) return { hasInput: false };
// Focus the input (simulates keyboard appearing)
input.focus();
// Check if important elements are still visible
const header = document.querySelector('header, .navbar, .page-header');
const submitBtn = document.querySelector('button[type="submit"], .btn-primary');
const headerVisible = header ? header.getBoundingClientRect().top >= 0 : true;
return {
hasInput: true,
headerVisible,
hasSubmitBtn: !!submitBtn
};
});
if (!result.hasInput) {
return { passed: null, skipped: true, message: 'No input field found' };
}
return {
passed: result.headerVisible,
message: result.headerVisible
? 'Layout stable when input focused'
: 'Header pushed off-screen when input focused'
};
},
async mobileDropdownsWork(page, baseUrl) {
await page.setViewport({ width: 375, height: 667, isMobile: true, hasTouch: true });
await navigateTo(page, `${baseUrl}/settings/`);
const result = await page.evaluate(() => {
const selects = document.querySelectorAll('select, .custom-dropdown, .ldr-dropdown');
if (selects.length === 0) return { hasDropdowns: false };
const firstSelect = selects[0];
const rect = firstSelect.getBoundingClientRect();
// Check if dropdown is within screen bounds
const isAccessible = rect.right <= window.innerWidth && rect.left >= 0;
// Check touch target size
const isTappable = rect.height >= 44; // iOS minimum touch target
return {
hasDropdowns: true,
dropdownCount: selects.length,
isAccessible,
isTappable,
height: rect.height
};
});
if (!result.hasDropdowns) {
return { passed: null, skipped: true, message: 'No dropdowns found' };
}
return {
passed: result.isAccessible && result.isTappable,
message: `Dropdowns: ${result.dropdownCount} found (accessible: ${result.isAccessible}, height: ${result.height}px)`
};
}
};
// ============================================================================
// Main Test Runner
// ============================================================================
async function main() {
log.section('Mobile Interactions Tests');
const ctx = await setupTest({ authenticate: true });
const results = new TestResults('Mobile Interactions 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 {
// Mobile Modal Tests
log.section('Mobile Modals');
await run('Modals', 'Create Form Fits Mobile', (p, u) => MobileModalTests.createFormFitsMobile(p, u));
await run('Modals', 'Modal Scrollable Content', (p, u) => MobileModalTests.modalScrollableContent(p, u));
// Mobile Navigation Tests
log.section('Mobile Navigation');
await run('Navigation', 'Mobile Menu Opens', (p, u) => MobileNavTests.mobileMenuOpens(p, u));
await run('Navigation', 'Mobile Menu Closes', (p, u) => MobileNavTests.mobileMenuCloses(p, u));
await run('Navigation', 'Bottom Nav Tabs Work', (p, u) => MobileNavTests.bottomNavTabsWork(p, u));
await run('Navigation', 'Swipe Gestures Work', (p, u) => MobileNavTests.swipeGesturesWork(p, u));
// Mobile Form Tests
log.section('Mobile Forms');
await run('Forms', 'Mobile Keyboard Doesnt Break Layout', (p, u) => MobileFormTests.mobileKeyboardDoesntBreakLayout(p, u));
await run('Forms', 'Mobile Dropdowns Work', (p, u) => MobileFormTests.mobileDropdownsWork(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 = { MobileModalTests, MobileNavTests, MobileFormTests };