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
479 lines
20 KiB
JavaScript
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 };
|