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

359 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
};