/** * Shared Test Utilities * * Provides common functionality for UI tests: * - Browser setup/teardown * - Configuration management * - Helper functions (delay, waitFor, screenshot) * - Logging utilities */ const puppeteer = require('puppeteer'); const fs = require('fs'); const path = require('path'); const AuthHelper = require('../auth_helper'); const { getPuppeteerLaunchOptions } = require('../puppeteer_config'); // Centralized configuration - single source of truth const config = { baseUrl: process.env.BASE_URL || process.env.TEST_BASE_URL || 'http://127.0.0.1:5000', isCI: process.env.CI === 'true', headless: process.env.HEADLESS !== 'false', screenshotDir: path.join(__dirname, '..', 'screenshots'), resultsDir: path.join(__dirname, '..', 'test-results'), timeout: parseInt(process.env.TEST_TIMEOUT, 10) || 30000, slowMo: parseInt(process.env.SLOW_MO, 10) || 0, }; // Default viewport settings const viewports = { desktop: { width: 1280, height: 800 }, mobile: { width: 375, height: 667, isMobile: true, hasTouch: true }, tablet: { width: 768, height: 1024, isMobile: true, hasTouch: true }, }; /** * Initialize test environment with browser and optional authentication * * @param {Object} options - Setup options * @param {boolean} [options.authenticate=true] - Whether to authenticate the user * @param {boolean} [options.headless] - Override headless mode * @param {Object} [options.viewport] - Viewport settings (or use 'desktop', 'mobile', 'tablet') * @param {string} [options.username] - Custom username for authentication * @param {string} [options.password] - Custom password for authentication * @returns {Promise} Context object with browser, page, authHelper, config */ async function setupTest(options = {}) { const launchOptions = getPuppeteerLaunchOptions({ headless: options.headless ?? config.headless, slowMo: options.slowMo ?? config.slowMo, }); const browser = await puppeteer.launch(launchOptions); const page = await browser.newPage(); // Set viewport let viewport = options.viewport; if (typeof viewport === 'string' && viewports[viewport]) { viewport = viewports[viewport]; } await page.setViewport(viewport || viewports.desktop); // Set default timeouts to avoid indefinite hangs in CI const timeout = config.isCI ? 60000 : config.timeout; page.setDefaultTimeout(timeout); page.setDefaultNavigationTimeout(timeout); // Create auth helper const authHelper = new AuthHelper(page, config.baseUrl); // Authenticate if requested (default: true) if (options.authenticate !== false) { const authTimeoutMs = config.isCI ? 120000 : 30000; await withTimeout( authHelper.ensureAuthenticated(options.username, options.password), authTimeoutMs, 'authentication' ); } // Return context object // Use authHelper.getPage() because ensureAuthenticated() may have created // a fresh page if the original one hit a detached frame error const finalPage = authHelper.getPage(); finalPage.setDefaultTimeout(timeout); finalPage.setDefaultNavigationTimeout(timeout); return { browser, page: finalPage, authHelper, config, viewports, }; } /** * Clean up test environment * * @param {Object} context - Context object from setupTest */ async function teardownTest(context) { if (context && context.browser) { await context.browser.close(); } } /** * Take a screenshot with auto-directory creation * * @param {Object} page - Puppeteer page object * @param {string} name - Screenshot name (without extension) * @param {Object} [options] - Screenshot options * @param {boolean} [options.fullPage=true] - Capture full page * @returns {Promise} Path to saved screenshot */ async function screenshot(page, name, options = {}) { const dir = config.screenshotDir; if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const filename = `${name}_${timestamp}.png`; const filepath = path.join(dir, filename); await page.screenshot({ path: filepath, fullPage: options.fullPage !== false, ...options, }); return filepath; } /** * Simple delay function * * @param {number} ms - Milliseconds to wait * @returns {Promise} */ const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); /** * Run a promise with a timeout. If the promise doesn't resolve within * the given time, it rejects with a TimeoutError. This prevents a single * hung sub-test from blocking the entire test suite until the 300s * process-level timeout kills it. * * @param {Promise} promise - The promise to race against the timeout * @param {number} ms - Timeout in milliseconds (default: 30s CI, 15s local) * @param {string} [label] - Description for the timeout error message * @returns {Promise} The original promise result or a timeout rejection */ function withTimeout(promise, ms, label = 'operation') { const timeoutMs = ms || (config.isCI ? 30000 : 15000); return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error(`Timeout: ${label} did not complete within ${timeoutMs / 1000}s`)); }, timeoutMs); promise.then( (val) => { clearTimeout(timer); resolve(val); }, (err) => { clearTimeout(timer); reject(err); } ); }); } /** * Navigate to a page, skipping if already there. * * Many CI tests call page.goto() for the same URL in every test function. * Each navigation takes 5-15s in CI, so 15 tests × 10s = 150s+ of pure * navigation overhead. This helper skips the navigation when the page URL * already matches, cutting test suite time significantly. * * @param {Object} page - Puppeteer page object * @param {string} url - Full URL to navigate to * @param {Object} [options] - Navigation options * @returns {Promise} */ async function navigateTo(page, url, options = {}) { const currentUrl = page.url(); // Check if already on the target page (compare path, ignore query/hash) const targetPath = new URL(url).pathname.replace(/\/$/, ''); try { const currentPath = new URL(currentUrl).pathname.replace(/\/$/, ''); if (currentPath === targetPath) { return null; } } catch { // about:blank or invalid URL — need to navigate } const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: config.isCI ? 60000 : config.timeout, ...options, }); return response; } /** * Wait for an element with better error messages * * @param {Object} page - Puppeteer page object * @param {string} selector - CSS selector * @param {Object} [options] - Wait options * @param {number} [options.timeout] - Timeout in ms * @param {boolean} [options.visible] - Wait for visible element * @returns {Promise} */ async function waitFor(page, selector, options = {}) { const timeout = options.timeout || config.timeout; try { await page.waitForSelector(selector, { timeout, visible: options.visible, }); return true; } catch { throw new Error(`Element "${selector}" not found after ${timeout}ms`); } } /** * Wait for element to become visible (using waitForFunction for reliability) * * @param {Object} page - Puppeteer page object * @param {string} selector - CSS selector * @param {number} [timeout] - Timeout in ms * @returns {Promise} */ async function waitForVisible(page, selector, timeout = 5000) { try { await page.waitForFunction( (sel) => { const el = document.querySelector(sel); if (!el) return false; const style = window.getComputedStyle(el); return style.display !== 'none' && style.visibility !== 'hidden'; }, { timeout }, selector ); return true; } catch { return false; } } /** * Click an element and wait for navigation * * @param {Object} page - Puppeteer page object * @param {string} selector - CSS selector to click * @param {Object} [options] - Navigation options * @returns {Promise} */ async function clickAndWaitForNavigation(page, selector, options = {}) { const waitUntil = options.waitUntil || 'domcontentloaded'; const timeout = options.timeout || config.timeout; await Promise.all([ page.waitForNavigation({ waitUntil, timeout }), page.click(selector), ]); } /** * Get the value of an input element * * @param {Object} page - Puppeteer page object * @param {string} selector - CSS selector * @returns {Promise} */ async function getInputValue(page, selector) { return page.$eval(selector, el => el.value); } /** * Clear and type into an input * * @param {Object} page - Puppeteer page object * @param {string} selector - CSS selector * @param {string} text - Text to type * @returns {Promise} */ async function clearAndType(page, selector, text) { await page.click(selector, { clickCount: 3 }); await page.keyboard.press('Backspace'); await page.type(selector, text); } /** * Find an action button on the current page by word-boundary keyword match * against its text content, and optionally click it. * * Word boundaries (`\b`) keep the match from firing on substrings — e.g. * the default keyword `new` matches "New Folder" but NOT "Back to News * Feed" (where "news" merely contains "new"). The original substring * matcher caused #4069. * * @param {import('puppeteer').Page} page * @param {Object} [options] * @param {string} [options.selectors] CSS selector list to scan. * Default: `'button, a.btn, .btn'`. * @param {string[]} [options.keywords] Lowercase words to match (combined * into `\b(?:k1|k2|...)\b`). Default: `['create', 'new', 'add']`. * @param {boolean} [options.click] If true, click the first match. * @returns {Promise<{found: boolean, text?: string}>} */ async function findActionButton(page, options = {}) { const { selectors = 'button, a.btn, .btn', keywords = ['create', 'new', 'add'], click = false, } = options; return await page.evaluate((selectorsStr, keywordsArr, doClick) => { const pattern = new RegExp(`\\b(?:${keywordsArr.join('|')})\\b`); const buttons = Array.from(document.querySelectorAll(selectorsStr)); const match = buttons.find(b => pattern.test((b.textContent || '').toLowerCase())); if (!match) return { found: false }; if (doClick) match.click(); return { found: true, text: match.textContent?.trim() }; }, selectors, keywords, click); } // Console logging with colors (works in terminal) const log = { info: (msg) => console.log(`\x1b[36m[INFO] ${msg}\x1b[0m`), success: (msg) => console.log(`\x1b[32m[PASS] ${msg}\x1b[0m`), error: (msg) => console.log(`\x1b[31m[FAIL] ${msg}\x1b[0m`), warning: (msg) => console.log(`\x1b[33m[WARN] ${msg}\x1b[0m`), section: (msg) => console.log(`\x1b[34m\n=== ${msg} ===\x1b[0m`), debug: (msg) => { if (process.env.DEBUG) { console.log(`\x1b[90m[DEBUG] ${msg}\x1b[0m`); } }, }; module.exports = { config, viewports, setupTest, teardownTest, screenshot, delay, withTimeout, navigateTo, waitFor, waitForVisible, clickAndWaitForNavigation, getInputValue, clearAndType, findActionButton, log, };