/** * Authentication Helper for UI Tests * Handles login and registration for Puppeteer tests */ const crypto = require('crypto'); /** * Timing utility for detailed performance logging */ class Timer { constructor(label) { this.label = label; this.startTime = Date.now(); this.laps = []; } lap(description) { const now = Date.now(); const elapsed = now - this.startTime; const lapTime = this.laps.length > 0 ? now - this.laps[this.laps.length - 1].timestamp : elapsed; this.laps.push({ description, elapsed, lapTime, timestamp: now }); return elapsed; } elapsed() { return Date.now() - this.startTime; } summary() { const total = this.elapsed(); console.log(` ⏱️ [${this.label}] TOTAL: ${total}ms (${(total/1000).toFixed(1)}s)`); return total; } } const DEFAULT_TEST_USER = { username: 'testuser', password: 'T3st!Secure#2024$LDR' }; // CI pre-created test user (created in GitHub workflow's "Initialize database" step) // This user is created BEFORE tests run, so no slow registration needed const CI_TEST_USER = { username: 'test_admin', password: 'testpass123' }; // Configuration constants - single source of truth for auth helper settings const AUTH_CONFIG = { // Route paths paths: { login: '/auth/login', register: '/auth/register', logout: '/auth/logout' }, // Timeouts (ms) - CI has longer timeouts due to: // 1. Slower CI runners with shared resources // 2. Registration creates encrypted SQLCipher database // 3. Key derivation from password is CPU intensive // 4. Creating 58 database tables takes time // 5. Importing 500+ settings from JSON files // Note: If registration takes >2min, something is likely wrong timeouts: { navigation: process.env.CI ? 60000 : 30000, // 1 min in CI (reduced from 3 min) formSelector: process.env.CI ? 30000 : 5000, // 30s in CI (reduced from 1 min) submitNavigation: process.env.CI ? 60000 : 60000, // 1 min in CI (fast KDF makes 2 min unnecessary) urlCheck: process.env.CI ? 10000 : 5000, // 10s in CI (reduced from 30s) errorCheck: process.env.CI ? 5000 : 2000, // 5s in CI (reduced from 15s) logout: process.env.CI ? 30000 : 10000 // 30s in CI (reduced from 1 min) }, // Delays (ms) delays: { retryNavigation: process.env.CI ? 2000 : 1000, // 2s between retries in CI afterRegistration: process.env.CI ? 5000 : 3000, // 5s after registration in CI beforeRetry: process.env.CI ? 5000 : 5000, // 5s before retry in CI afterLogout: process.env.CI ? 2000 : 1000 // 2s after logout in CI }, // CI-specific settings ci: { waitUntil: 'domcontentloaded', maxLoginAttempts: 10, // Reduced from 15 - fail faster maxNavigationRetries: 3 // Reduced from 5 - fail faster } }; // Generate random username for each test to avoid conflicts function generateRandomUsername() { const timestamp = Date.now(); let random; // Use rejection sampling to avoid bias const maxValue = 4294967295; // Max value for 32-bit unsigned int const limit = maxValue - (maxValue % 1000); // Largest multiple of 1000 that fits do { random = crypto.randomBytes(4).readUInt32BE(0); } while (random >= limit); // Reject values that would cause bias random %= 1000; return `testuser_${timestamp}_${random}`; } class AuthHelper { constructor(page, baseUrl = 'http://127.0.0.1:5000') { this.page = page; this.baseUrl = baseUrl; this.isCI = !!process.env.CI; } /** * Get the current page reference. * The page may have been replaced if a detached frame error was recovered. * Tests should call this after ensureAuthenticated() to get the working page. */ getPage() { return this.page; } /** * Helper method for delays */ async _delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Wait for server to become responsive * Uses Node.js http.get instead of page.evaluate to avoid Puppeteer's * protocolTimeout (600s) when the page is in a bad state after navigation failure. */ async _waitForServerReady(maxWaitMs = 120000, checkIntervalMs = 5000) { const http = require('http'); const startTime = Date.now(); while (Date.now() - startTime < maxWaitMs) { try { const ok = await new Promise((resolve) => { const req = http.get(this.baseUrl, { timeout: 10000 }, (res) => { resolve(res.statusCode >= 200 && res.statusCode < 400); res.resume(); // drain the response }); req.on('error', () => resolve(false)); req.on('timeout', () => { req.destroy(); resolve(false); }); }); if (ok) { return true; } } catch { // ignore - will retry } await this._delay(checkIntervalMs); } console.log(` Server did not become ready within ${maxWaitMs/1000}s`); return false; } /** * Navigate to an auth page with CI-aware retry logic * @param {string} path - The path to navigate to (e.g., AUTH_CONFIG.paths.login) * @param {string} expectedPathSegment - Path segment to verify arrival (e.g., '/auth/login') * @returns {string} The URL we arrived at */ async _navigateToAuthPage(path, expectedPathSegment) { const timer = new Timer(`nav:${path}`); const targetUrl = `${this.baseUrl}${path}`; const waitUntil = this.isCI ? AUTH_CONFIG.ci.waitUntil : 'networkidle2'; const maxRetries = this.isCI ? AUTH_CONFIG.ci.maxNavigationRetries : 1; const timeout = AUTH_CONFIG.timeouts.navigation; let arrivedUrl = ''; let lastError = null; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { timer.lap(`attempt ${attempt} start`); await this.page.goto(targetUrl, { waitUntil, timeout }); timer.lap(`attempt ${attempt} navigation complete`); arrivedUrl = this.page.url(); // Check if we arrived at expected page if (arrivedUrl.includes(expectedPathSegment)) { return arrivedUrl; } // If redirected somewhere else (like home when logged in), that's also OK if (!arrivedUrl.includes('/auth/')) { return arrivedUrl; } // If we didn't get where we wanted, retry if (attempt < maxRetries) { await this._delay(AUTH_CONFIG.delays.retryNavigation); } } catch (navError) { lastError = navError; // If the frame is detached, create a fresh page and retry if (navError.message.includes('detached') || navError.message.includes('destroyed')) { try { const browser = this.page.browser(); const newPage = await browser.newPage(); try { await this.page.close(); } catch { /* already broken */ } this.page = newPage; } catch { // Could not create fresh page } } if (attempt < maxRetries) { await this._delay(AUTH_CONFIG.delays.retryNavigation); } } } // If we got a URL but it wasn't what we expected, return it anyway if (arrivedUrl) { return arrivedUrl; } // If we never got a URL, throw the last error throw lastError || new Error(`Failed to navigate to ${path} after ${maxRetries} attempts`); } /** * Check if user is logged in by looking for logout button or username */ async isLoggedIn() { try { const url = this.page.url(); if (url.includes(AUTH_CONFIG.paths.login)) { return false; } // Check for logout button/link const logoutSelectors = [ 'a.logout-btn', '#logout-form', 'form[action="/auth/logout"]', 'a[onclick*="logout"]' ]; for (const selector of logoutSelectors) { try { const element = await this.page.$(selector); if (element) { return true; } } catch { // Some selectors might not be valid, continue } } // Check if we can access protected pages const currentUrl = this.page.url(); if (currentUrl.includes('/settings') || currentUrl.includes('/metrics') || currentUrl.includes('/history')) { return true; } // If we're on the home page, check for research form const researchForm = await this.page.$('form[action*="research"], #query, button[type="submit"]'); if (researchForm) { return true; } return false; } catch { return false; } } /** * Login with existing user credentials */ async login(username = DEFAULT_TEST_USER.username, password = DEFAULT_TEST_USER.password) { const timer = new Timer('login'); console.log(`🔐 Attempting login as ${username}...`); // Check if already logged in if (await this.isLoggedIn()) { console.log('✅ Already logged in'); timer.summary(); return true; } timer.lap('checked login status'); // Always navigate to login page to ensure fresh CSRF token // (After logout, the page may have a stale token from the previous session) await this._navigateToAuthPage(AUTH_CONFIG.paths.login, AUTH_CONFIG.paths.login); timer.lap('navigated to login page'); // Wait for login form await this.page.waitForSelector('input[name="username"]', { timeout: AUTH_CONFIG.timeouts.formSelector }); timer.lap('login form ready'); // Clear fields first to ensure clean state await this.page.$eval('input[name="username"]', el => { el.value = ''; }); await this.page.$eval('input[name="password"]', el => { el.value = ''; }); // Type credentials await this.page.type('input[name="username"]', username); await this.page.type('input[name="password"]', password); timer.lap('credentials filled'); // Listen to page errors this.page.on('pageerror', error => console.log(' Page error:', error.message)); try { // In CI, use a simpler and faster approach // NOTE: Previous implementation used a polling loop that called page.evaluate() // 30 times with 10s timeouts each. When the page was navigating, evaluate() // would hang, causing 5+ minute delays ("URL check timeout" x30). // This simpler Promise.all approach completes in seconds. if (this.isCI) { timer.lap('starting CI login'); // Use Promise.all with waitForNavigation - the standard Puppeteer approach // This is more reliable than polling page.evaluate() which hangs during navigation try { await Promise.all([ this.page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: AUTH_CONFIG.timeouts.navigation // 60s in CI, 30s locally }), this.page.click('button[type="submit"]') ]); timer.lap('navigation complete after submit'); } catch (navError) { timer.lap(`navigation error: ${navError.message.substring(0, 50)}`); // Check if we actually succeeded despite the error const currentUrl = this.page.url(); if (!currentUrl.includes(AUTH_CONFIG.paths.login)) { // Actually redirected successfully } else { // Check for session cookie - login may have worked const cookies = await this.page.cookies(); const sessionCookie = cookies.find(c => c.name === 'session'); if (sessionCookie) { // NOTE: Using configured navigation timeout instead of hardcoded 15s // because CI runners can be slow and 15s often isn't enough await this.page.goto(this.baseUrl, { waitUntil: 'domcontentloaded', timeout: AUTH_CONFIG.timeouts.navigation // 60s in CI }); timer.lap('navigated to home via cookie'); } else { // Check for error message const errorEl = await this.page.$('.alert-danger, .error-message'); if (errorEl) { const errorText = await this.page.evaluate(el => el.textContent.trim(), errorEl); throw new Error(`Login failed: ${errorText}`); } throw new Error('Login failed - no redirect, no cookie'); } } } timer.lap('CI login complete'); } else { // Non-CI logic - use domcontentloaded instead of networkidle2 to avoid WebSocket/polling timeouts await Promise.all([ this.page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: AUTH_CONFIG.timeouts.submitNavigation }), this.page.click('button[type="submit"]') ]); } } catch (navError) { timer.lap(`navigation error: ${navError.message.substring(0, 40)}`); timer.summary(); throw navError; } // Check if login was successful const finalUrl = this.page.url(); if (finalUrl.includes(AUTH_CONFIG.paths.login)) { // Still on login page - check for error const error = await this.page.$('.alert-danger, .error-message, .alert'); if (error) { const errorText = await this.page.evaluate(el => el.textContent, error); timer.summary(); throw new Error(`Login failed: ${errorText.trim()}`); } timer.summary(); throw new Error('Login failed - still on login page'); } timer.summary(); console.log('✅ Login successful'); return true; } /** * Register a new user */ async register(username = DEFAULT_TEST_USER.username, password = DEFAULT_TEST_USER.password) { const timer = new Timer('register'); console.log(`📝 Attempting registration for ${username}...`); // Navigate to registration page using the helper const arrivedUrl = await this._navigateToAuthPage(AUTH_CONFIG.paths.register, AUTH_CONFIG.paths.register); timer.lap('navigation complete'); // If redirected to login, registration might be disabled if (arrivedUrl.includes(AUTH_CONFIG.paths.login)) { throw new Error('Registration page redirected to login - registrations may be disabled'); } // Wait for registration form await this.page.waitForSelector('input[name="username"]', { timeout: AUTH_CONFIG.timeouts.formSelector }); timer.lap('form ready'); await this.page.type('input[name="username"]', username); await this.page.type('input[name="password"]', password); await this.page.type('input[name="confirm_password"]', password); timer.lap('form filled'); // Check acknowledgment checkbox if present const acknowledgeCheckbox = await this.page.$('input[name="acknowledge"]'); if (acknowledgeCheckbox) { await this.page.click('input[name="acknowledge"]'); timer.lap('checkbox clicked'); } // Submit form - use CI-specific approach with waitForNavigation + retries if (this.isCI) { // In CI, registration can take 2+ minutes due to encrypted DB creation. // The page.evaluate() approach doesn't work because the page is unresponsive // during server processing. Instead, we use waitForNavigation with retries. let registrationSucceeded = false; try { await Promise.all([ this.page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: AUTH_CONFIG.timeouts.submitNavigation // 5 minutes }), this.page.click('button[type="submit"]') ]); const currentUrl = this.page.url(); if (!currentUrl.includes(AUTH_CONFIG.paths.register)) { registrationSucceeded = true; } } catch (navError) { // Handle frame detachment (page was replaced) if (navError.message.includes('detached') || navError.message.includes('destroyed')) { await this._delay(AUTH_CONFIG.delays.afterRegistration); // The current page's frame is broken - get a fresh page from the browser try { const browser = this.page.browser(); const newPage = await browser.newPage(); try { await this.page.close(); } catch { /* already broken */ } this.page = newPage; await this.page.goto(this.baseUrl, { waitUntil: AUTH_CONFIG.ci.waitUntil, timeout: AUTH_CONFIG.timeouts.formSelector }); registrationSucceeded = true; } catch { // Could not navigate to home after frame detachment } } // For timeout errors, fall through to session verification below } // If navigation didn't clearly succeed, verify via session with retries if (!registrationSucceeded) { // Ensure we have a working page (may have been replaced above) try { await this.page.url(); } catch { const browser = this.page.browser(); try { await this.page.close(); } catch { /* already broken */ } this.page = await browser.newPage(); } // Wait for server to become ready (might be still processing) // In CI, registration can take 2+ minutes due to encrypted DB creation const serverReady = await this._waitForServerReady(180000, 10000); // 3 min max, check every 10s if (serverReady) { // Try to navigate and verify session for (let retryAttempt = 1; retryAttempt <= 3; retryAttempt++) { try { await this.page.goto(this.baseUrl, { waitUntil: AUTH_CONFIG.ci.waitUntil, timeout: AUTH_CONFIG.timeouts.formSelector }); const homeUrl = this.page.url(); if (!homeUrl.includes('/auth/login') && !homeUrl.includes('/auth/register')) { const logoutBtn = await this.page.$('#logout-form, a[href*="logout"], .logout'); if (logoutBtn) { registrationSucceeded = true; break; } } } catch { if (retryAttempt < 3) { await this._delay(10000); // Wait 10s before retry } } } } } if (registrationSucceeded) { console.log('✅ Registration successful'); return true; } // Check if still on registration page with an error const currentUrl = this.page.url(); if (currentUrl.includes(AUTH_CONFIG.paths.register)) { try { const error = await this.page.$('.alert-danger:not(.alert-warning), .error-message'); if (error) { const errorText = await this.page.evaluate(el => el.textContent, error); if (errorText.includes('already exists')) { console.log('⚠️ User already exists, attempting login instead'); return await this.login(username, password); } throw new Error(`Registration failed: ${errorText.trim()}`); } } catch (e) { if (e.message.includes('Registration failed')) throw e; // Ignore error checking errors } } throw new Error('Registration failed - could not verify success'); } // Non-CI logic - use waitForNavigation try { await Promise.all([ this.page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: AUTH_CONFIG.timeouts.submitNavigation }), this.page.click('button[type="submit"]') ]); timer.lap('form submitted + navigation complete'); } catch (navError) { timer.lap(`submit error: ${navError.message.substring(0, 50)}`); // Handle frame detachment errors if (navError.message.includes('detached')) { // Wait for registration to complete server-side await this._delay(AUTH_CONFIG.delays.afterRegistration); timer.lap('post-registration delay complete'); // The current page's frame is broken - get a fresh page try { const browser = this.page.browser(); const newPage = await browser.newPage(); try { await this.page.close(); } catch { /* already broken */ } this.page = newPage; await this.page.goto(this.baseUrl, { waitUntil: 'domcontentloaded', timeout: AUTH_CONFIG.timeouts.formSelector }); timer.lap('navigated to home'); } catch { // Could not navigate after registration } timer.summary(); console.log('✅ Registration completed'); return true; } timer.summary(); throw navError; } // Check if registration was successful const currentUrl = this.page.url(); timer.lap('checking result URL'); if (currentUrl.includes(AUTH_CONFIG.paths.register)) { // Still on registration page - check for actual errors (not warnings) const error = await this.page.$('.alert-danger:not(.alert-warning), .error-message'); if (error) { const errorText = await this.page.evaluate(el => el.textContent, error); if (errorText.includes('already exists')) { console.log('⚠️ User already exists, attempting login instead'); timer.summary(); return await this.login(username, password); } timer.summary(); throw new Error(`Registration failed: ${errorText}`); } // Check for security warnings (these are not errors) const warning = await this.page.$('.alert-warning'); if (warning) { const warningText = await this.page.evaluate(el => el.textContent, warning); console.log('⚠️ Security warning:', warningText.trim().replace(/\s+/g, ' ')); } timer.summary(); throw new Error('Registration failed - still on registration page'); } timer.summary(); console.log('✅ Registration successful'); return true; } /** * Ensure user is authenticated - register if needed, then login * In CI mode, first tries the pre-created CI test user for speed */ async ensureAuthenticated(username = null, password = DEFAULT_TEST_USER.password, retries = null) { const timer = new Timer('ensureAuthenticated'); // Use more retries in CI environment if (retries === null) { retries = this.isCI ? 2 : 2; } // Check if already logged in try { if (await this.isLoggedIn()) { console.log('✅ Already logged in'); timer.summary(); return true; } } catch (checkError) { console.log(`⚠️ Could not check login status: ${checkError.message}`); // Continue with authentication attempt } timer.lap('login status checked'); // In CI, try the pre-created CI test user first (much faster than registration!) // If CI login fails, fall back to registration (slower but reliable). // This allows incremental migration - workflows with init_test_database.py // get the speed benefit, while others still work via registration fallback. if (this.isCI) { try { console.log('🔐 Trying CI test user login...'); await this.login(CI_TEST_USER.username, CI_TEST_USER.password); console.log('✅ Logged in with CI test user'); timer.summary(); return true; } catch (ciLoginError) { console.log(`⚠️ CI test user login failed: ${ciLoginError.message.substring(0, 80)}`); // Fall back to registration (slower but reliable) } } // --------------------------------------------------------------- // GOTCHA — fresh-user fallback can masquerade as a server FD/DB leak. // // When the CI test-user login above fails, every test falls back to // registering a BRAND-NEW user (generateRandomUsername() below). The // most common cause locally is the shared `test_admin` getting // failed-login *lockout-locked* after several iterations — once that // happens, each test in a shard registers its own `testuser_`. // // Each fresh user gets its own per-user encrypted DB + SQLAlchemy // engine on the server. Those engines are only disposed by logout or // the periodic connection-cleanup sweep (~300s; see // docs/developing/resource-cleanup.md and ADR-0004), so within a // single sub-300s shard run they accumulate. The server's open // file-descriptor count to encrypted_databases/*.db(-wal/-shm) then // climbs ~linearly across tests — which looks EXACTLY like a per-user // DB connection leak but is purely this test artifact. // // In real CI a single working `test_admin` is reused, so there is one // engine and FDs stay bounded by the pool cap (pool_size 20 + // max_overflow 40 = 60). Before chasing a "chat shards leak / hang", // confirm you are NOT in this fallback: grep the server log for many // distinct `testuser_` opens. The chat-shard CI failures // themselves are runner contention (60s navigation timeouts under a // heavily-loaded Docker runner), not a server-side connection leak — // both chat shards pass locally in faithful CI mode with bounded FDs. // --------------------------------------------------------------- // Generate random username if not provided if (!username) { username = generateRandomUsername(); } let lastError; for (let attempt = 1; attempt <= retries; attempt++) { try { return await this.register(username, password); } catch (registerError) { // If user already exists, try login if (registerError.message.includes('already exists') || registerError.message.includes('still on registration page')) { try { return await this.login(username, password); } catch (loginError) { lastError = loginError; } } else { lastError = registerError; } console.log(`🔄 Auth attempt ${attempt}/${retries} failed: ${lastError.message.substring(0, 80)}`); // If timeout or network error, wait and retry if (attempt < retries && (lastError.message.includes('timeout') || lastError.message.includes('net::') || lastError.message.includes('Navigation'))) { await this._delay(AUTH_CONFIG.delays.beforeRetry); continue; } if (attempt === retries) { console.log(`❌ Auth failed after ${retries} attempts. Last error: ${lastError.message}`); timer.summary(); throw lastError; } } } timer.summary(); throw lastError || new Error('Failed to authenticate after retries'); } /** * Wrapper around ensureAuthenticated() with a timeout guard. * Prevents auth from hanging indefinitely in CI when the server is unresponsive. * Default: 120s CI, 30s local. */ async ensureAuthenticatedWithTimeout(username = null, password = DEFAULT_TEST_USER.password, timeoutMs = null) { if (timeoutMs === null) { timeoutMs = this.isCI ? 120000 : 30000; } return new Promise((resolve, reject) => { const timer = setTimeout(() => { console.log(`⏱️ Auth timeout after ${timeoutMs / 1000}s — ensureAuthenticated did not complete`); reject(new Error(`Auth timeout: did not complete within ${timeoutMs / 1000}s`)); }, timeoutMs); this.ensureAuthenticated(username, password).then( (val) => { clearTimeout(timer); resolve(val); }, (err) => { clearTimeout(timer); reject(err); } ); }); } /** * Logout the current user */ async logout() { console.log('🚪 Logging out...'); try { // Try to find and submit the logout form directly (more reliable than clicking link) const logoutForm = await this.page.$('#logout-form'); if (logoutForm) { await Promise.all([ this.page.waitForNavigation({ waitUntil: 'networkidle2', timeout: AUTH_CONFIG.timeouts.logout }).catch(() => {}), this.page.evaluate(() => { document.getElementById('logout-form').submit(); }) ]); } else { const logoutLink = await this.page.$('a.logout-btn'); if (logoutLink) { await Promise.all([ this.page.waitForNavigation({ waitUntil: 'networkidle2', timeout: AUTH_CONFIG.timeouts.logout }).catch(() => {}), this.page.click('a.logout-btn') ]); } else { // Last resort: navigate directly to logout URL await this.page.goto(`${this.page.url().split('/').slice(0, 3).join('/')}${AUTH_CONFIG.paths.logout}`, { waitUntil: 'networkidle2', timeout: AUTH_CONFIG.timeouts.logout }); } } // Give it a moment for any redirects await this._delay(AUTH_CONFIG.delays.afterLogout); const currentUrl = this.page.url(); const loginForm = await this.page.$('form[action*="login"], input[name="username"]'); if (loginForm || currentUrl.includes(AUTH_CONFIG.paths.login)) { console.log('✅ Logged out successfully'); } else { // Double-check by trying to access a protected page await this.page.goto(`${this.page.url().split('/').slice(0, 3).join('/')}/settings/`, { waitUntil: 'networkidle2', timeout: AUTH_CONFIG.timeouts.formSelector }).catch(() => {}); const finalUrl = this.page.url(); if (!finalUrl.includes(AUTH_CONFIG.paths.login)) { console.log(`Warning: May not be fully logged out. Current URL: ${finalUrl}`); } else { console.log('✅ Logged out successfully'); } } } catch (error) { console.log(`⚠️ Logout error: ${error.message}`); } } } /** * Safe click utility - waits for element to be visible and clickable * Use this instead of direct element.click() to avoid "not clickable" errors * * @param {Page} page - Puppeteer page object * @param {string|ElementHandle} selectorOrElement - CSS selector or element handle * @param {Object} options - Options for the click * @param {number} options.timeout - Max time to wait for element (default: 10000ms) * @param {boolean} options.scrollIntoView - Scroll element into view before clicking (default: true) * @returns {Promise} - True if click succeeded */ async function safeClick(page, selectorOrElement, options = {}) { const timeout = options.timeout || (process.env.CI ? 15000 : 10000); const scrollIntoView = options.scrollIntoView !== false; let element; // Get the element handle if (typeof selectorOrElement === 'string') { try { await page.waitForSelector(selectorOrElement, { visible: true, timeout }); element = await page.$(selectorOrElement); } catch { console.log(`safeClick: Element not found or not visible: ${selectorOrElement}`); return false; } } else { element = selectorOrElement; } if (!element) { console.log('safeClick: No element to click'); return false; } try { // Scroll element into view if needed if (scrollIntoView) { await page.evaluate(el => { el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'center' }); }, element); // Small delay after scrolling await new Promise(r => setTimeout(r, 100)); } // Wait for element to be in a clickable state await page.evaluate(el => { return new Promise((resolve, reject) => { const checkClickable = () => { const rect = el.getBoundingClientRect(); const isVisible = rect.width > 0 && rect.height > 0; const isInViewport = rect.top >= 0 && rect.left >= 0; const style = window.getComputedStyle(el); const notHidden = style.visibility !== 'hidden' && style.display !== 'none'; if (isVisible && notHidden) { resolve(true); } else { reject(new Error('Element not clickable')); } }; // Check immediately and after a short delay setTimeout(checkClickable, 50); }); }, element); // Perform the click await element.click(); return true; } catch (clickError) { console.log(`safeClick: Click failed - ${clickError.message}`); // Fallback: try clicking via JavaScript try { await page.evaluate(el => el.click(), element); console.log('safeClick: Fallback JS click succeeded'); return true; } catch (jsClickError) { console.log(`safeClick: JS click also failed - ${jsClickError.message}`); return false; } } } /** * Wait for a selector to be present and visually stable. * * Use this instead of `await delay(N)` after an action that triggers UI to * settle (e.g., a button click that reveals a panel, a typed input that * triggers debounced validation). It resolves as soon as the element's * bounding box hasn't moved for `idleMs` of `requestAnimationFrame` ticks, * up to a 3s in-page budget. * * DO NOT replace `delay()` calls that intentionally exercise wall-clock * behavior (e.g., a 10s timer the app is supposed to respect) — those tests * need real elapsed time, not a settle wait. * * @param {Page} page - Puppeteer page object * @param {string} selector - CSS selector to wait for * @param {Object} [options] * @param {number} [options.timeout=5000] Max time to wait for the selector to appear * @param {number} [options.idleMs=200] Final settle pause after layout stabilizes */ async function waitForStable(page, selector, options = {}) { const timeout = options.timeout || 5000; const idleMs = options.idleMs ?? 200; await page.waitForSelector(selector, { visible: true, timeout }); await page.evaluate(async (sel, idle) => { const el = document.querySelector(sel); if (!el) return; let last = el.getBoundingClientRect(); const start = Date.now(); // Bounded in-page poll: returns as soon as the layout settles, or after // 3s if the element keeps moving (caller can extend via outer logic). while (Date.now() - start < 3000) { await new Promise((r) => requestAnimationFrame(r)); const now = el.getBoundingClientRect(); if ( now.x === last.x && now.y === last.y && now.width === last.width && now.height === last.height ) { await new Promise((r) => setTimeout(r, idle)); return; } last = now; } }, selector, idleMs); } module.exports = AuthHelper; module.exports.safeClick = safeClick; module.exports.waitForStable = waitForStable; module.exports.AUTH_CONFIG = AUTH_CONFIG; module.exports.Timer = Timer; module.exports.CI_TEST_USER = CI_TEST_USER; module.exports.DEFAULT_TEST_USER = DEFAULT_TEST_USER;