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
359 lines
12 KiB
JavaScript
359 lines
12 KiB
JavaScript
/**
|
||
* 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<Object>} 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<string>} 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<void>}
|
||
*/
|
||
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<void>}
|
||
*/
|
||
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<boolean>}
|
||
*/
|
||
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<boolean>}
|
||
*/
|
||
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<void>}
|
||
*/
|
||
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<string>}
|
||
*/
|
||
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<void>}
|
||
*/
|
||
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,
|
||
};
|