项目文件夹

文件
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

591 行
25 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/**
* Comprehensive Responsive UI Testing
* Tests UI responsiveness across different viewport sizes
*/
const puppeteer = require('puppeteer');
const AuthHelper = require('./auth_helper');
const { getPuppeteerLaunchOptions } = require('./puppeteer_config');
const fs = require('fs');
const path = require('path');
// Viewport configurations
const VIEWPORTS = {
mobile: { width: 375, height: 667, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
tablet: { width: 768, height: 1024, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
desktop: { width: 1280, height: 720, deviceScaleFactor: 1, isMobile: false, hasTouch: false }
};
// Pages to test - expanded to include subpages
const TEST_PAGES = [
// Main pages
{ name: 'Home', path: '/' },
{ name: 'Settings', path: '/settings/' },
{ name: 'Metrics', path: '/metrics/' },
{ name: 'History', path: '/history/' },
{ name: 'News', path: '/news/' },
// Metrics subpages (actual routes from metrics_routes.py)
{ name: 'Metrics-ContextOverflow', path: '/metrics/context-overflow' },
{ name: 'Metrics-Costs', path: '/metrics/costs' },
{ name: 'Metrics-StarReviews', path: '/metrics/star-reviews' },
{ name: 'Metrics-Links', path: '/metrics/links' },
// News subpages
{ name: 'News-Subscriptions', path: '/news/subscriptions' },
{ name: 'News-NewSubscription', path: '/news/subscriptions/new' },
// Other important pages
{ name: 'Benchmark', path: '/benchmark/' },
{ name: 'BenchmarkResults', path: '/benchmark/results' },
// Note: Research results pages require a research_id so can't be tested generically
// e.g. /results/<research_id>, /progress/<research_id>, /details/<research_id>
];
class ResponsiveUITester {
constructor(viewport = 'desktop') {
this.viewport = viewport;
this.baseUrl = process.env.TEST_BASE_URL || 'http://127.0.0.1:5000';
this.results = {
passed: 0,
failed: 0,
warnings: 0,
issues: []
};
// Create screenshots directory
this.screenshotsDir = path.join(__dirname, 'screenshots', viewport);
if (!process.env.CI && !fs.existsSync(this.screenshotsDir)) {
fs.mkdirSync(this.screenshotsDir, { recursive: true });
}
// Visual mode for debugging
this.visualMode = process.env.VISUAL_MODE === 'true' || process.env.HEADLESS === 'false';
}
/**
* Take a screenshot with a graceful fallback when the page exceeds
* Puppeteer's fullPage screenshot ceiling (16384px tall on Chromium).
*
* The diagnostic screenshots in this test are nice-to-have, not the
* test target — failing to capture a screenshot on an over-tall page
* (Settings / Metrics on narrow mobile viewports) used to bubble up to
* `testPage`'s catch block and mark the whole page as failed. This
* helper preserves the fullPage attempt where possible and falls back
* to a viewport-only screenshot on the documented "Page is too large"
* protocol error so the run continues and the failure surface stays
* about real responsive issues.
*/
async safeScreenshot(opts) {
try {
await this.page.screenshot(opts);
return true;
} catch (error) {
const msg = error && error.message ? error.message : String(error);
if (msg.includes('Page is too large') || msg.includes('captureScreenshot')) {
try {
await this.page.screenshot({ ...opts, fullPage: false });
console.log(` ️ Page exceeded screenshot size limit; saved viewport-only fallback (${opts.path})`);
return true;
} catch (fallbackError) {
console.log(` ⚠️ Screenshot fallback also failed: ${fallbackError.message}`);
return false;
}
}
console.log(` ⚠️ Screenshot failed: ${msg}`);
return false;
}
}
async init() {
console.log('Launching browser...');
this.browser = await puppeteer.launch(getPuppeteerLaunchOptions());
console.log('Creating new page...');
this.page = await this.browser.newPage();
await this.page.setViewport(VIEWPORTS[this.viewport]);
// First, capture login page screenshots before authentication
await this.captureLoginPageScreenshots();
// Authenticate with timeout protection
console.log('Authenticating...');
const auth = new AuthHelper(this.page, this.baseUrl);
// Use a reasonable timeout for authentication
const authTimeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Authentication timeout')), 30000)
);
try {
// For CI, use the pre-created test user from the workflow
if (process.env.CI) {
const testUser = {
username: 'test_admin',
password: 'testpass123' // pragma: allowlist secret
};
console.log(`Using pre-created test user: ${testUser.username}`);
await Promise.race([
auth.ensureAuthenticated(testUser.username, testUser.password),
authTimeout
]);
} else {
await Promise.race([
auth.ensureAuthenticated(),
authTimeout
]);
}
console.log('Authentication successful');
} catch (error) {
console.error('Authentication failed:', error.message);
throw error;
}
}
async captureLoginPageScreenshots() {
console.log(`📸 Capturing login page screenshots for ${this.viewport} viewport...`);
try {
// Navigate to login page
const loginUrl = `${this.baseUrl}/auth/login`;
await this.page.goto(loginUrl, {
waitUntil: 'domcontentloaded',
timeout: 30000
});
// Wait for login form to be visible
await this.page.waitForSelector('input[name="username"]', { timeout: 5000 });
// Take screenshot of the login page
if (!process.env.CI) {
const screenshotPath = path.join(this.screenshotsDir, `login-page-${this.viewport}.png`);
await this.safeScreenshot({
path: screenshotPath,
fullPage: true
});
console.log(` ✅ Login page screenshot saved: ${screenshotPath}`);
}
// Check for responsive issues on login page
const hasHorizontalOverflow = await this.page.evaluate(() => {
return document.body.scrollWidth > window.innerWidth;
});
if (hasHorizontalOverflow) {
console.log(` ⚠️ Login page has horizontal overflow at ${this.viewport}`);
if (!process.env.CI) {
const overflowScreenshotPath = path.join(this.screenshotsDir, `login-page-overflow-${this.viewport}.png`);
await this.safeScreenshot({
path: overflowScreenshotPath,
fullPage: true
});
console.log(` 📸 Overflow screenshot saved: ${overflowScreenshotPath}`);
}
}
// Check touch target sizes for mobile
if (this.viewport === 'mobile') {
const hasSmallTouchTargets = await this.page.evaluate(() => {
const clickables = document.querySelectorAll('button, input, a');
for (const el of clickables) {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0 && (rect.width < 44 || rect.height < 44)) {
return true;
}
}
return false;
});
if (hasSmallTouchTargets) {
console.log(` ⚠️ Login page has small touch targets for mobile`);
if (!process.env.CI) {
// Highlight small touch targets
await this.page.evaluate(() => {
const clickables = document.querySelectorAll('button, input, a');
clickables.forEach(el => {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0 && (rect.width < 44 || rect.height < 44)) {
el.style.outline = '2px dashed red';
el.style.outlineOffset = '2px';
}
});
});
const touchTargetScreenshotPath = path.join(this.screenshotsDir, `login-page-touch-targets-${this.viewport}.png`);
await this.safeScreenshot({
path: touchTargetScreenshotPath,
fullPage: true
});
console.log(` 📸 Touch target screenshot saved: ${touchTargetScreenshotPath}`);
}
}
}
// Also capture registration page screenshot
const registerUrl = `${this.baseUrl}/auth/register`;
await this.page.goto(registerUrl, {
waitUntil: 'domcontentloaded',
timeout: 30000
});
// Wait for registration form to be visible
await this.page.waitForSelector('input[name="username"]', { timeout: 5000 });
if (!process.env.CI) {
const registerScreenshotPath = path.join(this.screenshotsDir, `register-page-${this.viewport}.png`);
await this.safeScreenshot({
path: registerScreenshotPath,
fullPage: true
});
console.log(` ✅ Registration page screenshot saved: ${registerScreenshotPath}`);
}
console.log(`✅ Login/register page screenshots captured for ${this.viewport}`);
} catch (error) {
console.error(`❌ Error capturing login page screenshots: ${error.message}`);
// Don't throw - this is not critical for the test to continue
}
}
async testPage(pageInfo) {
const url = `${this.baseUrl}${pageInfo.path}`;
console.log(`Testing ${pageInfo.name} at ${this.viewport} viewport...`);
try {
const response = await this.page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: 30000
});
// Check if page exists (skip 404s)
if (response && response.status() === 404) {
console.log(` ⚠️ Skipping ${pageInfo.name} - page not found (404)`);
return;
}
// Check if we were redirected to login page (indicates session lost)
const currentUrl = this.page.url();
if (currentUrl.includes('/auth/login') || currentUrl.includes('/auth/register')) {
console.log(` ⚠️ Redirected to auth page - re-authenticating...`);
// Re-authenticate if needed
const authHelper = new AuthHelper(this.page);
const authResult = await authHelper.ensureAuthenticated();
if (authResult.authenticated) {
// Try navigating to the page again
await this.page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: 30000
});
}
}
// Check for horizontal overflow
const hasHorizontalOverflow = await this.page.evaluate(() => {
return document.body.scrollWidth > window.innerWidth;
});
if (hasHorizontalOverflow) {
this.results.warnings++;
this.results.issues.push(`⚠️ ${pageInfo.name}: Horizontal overflow detected at ${this.viewport}`);
// Take screenshot if not in CI
if (!process.env.CI) {
const screenshotPath = path.join(this.screenshotsDir, `${pageInfo.name.toLowerCase()}-overflow.png`);
await this.safeScreenshot({ path: screenshotPath, fullPage: true });
console.log(` 📸 Screenshot saved: ${screenshotPath}`);
}
}
// Check for overlapping elements
const hasOverlaps = await this.checkForOverlaps();
if (hasOverlaps) {
this.results.warnings++;
this.results.issues.push(`⚠️ ${pageInfo.name}: Overlapping elements detected at ${this.viewport}`);
// Highlight overlapping elements and take screenshot if not in CI
if (!process.env.CI) {
await this.page.evaluate(() => {
const elements = document.querySelectorAll('button, a, input, select');
elements.forEach(el => {
el.style.outline = '2px solid red';
});
});
const screenshotPath = path.join(this.screenshotsDir, `${pageInfo.name.toLowerCase()}-overlaps.png`);
await this.safeScreenshot({ path: screenshotPath, fullPage: true });
console.log(` 📸 Screenshot saved: ${screenshotPath}`);
}
}
// Check text readability
const hasSmallText = await this.page.evaluate(() => {
const elements = document.querySelectorAll('*');
for (const el of elements) {
const style = window.getComputedStyle(el);
const fontSize = parseFloat(style.fontSize);
if (fontSize > 0 && fontSize < 12 && el.textContent.trim()) {
return true;
}
}
return false;
});
if (hasSmallText && this.viewport === 'mobile') {
this.results.warnings++;
this.results.issues.push(`⚠️ ${pageInfo.name}: Text too small for mobile viewing`);
// Highlight small text and take screenshot if not in CI
if (!process.env.CI) {
await this.page.evaluate(() => {
const elements = document.querySelectorAll('*');
elements.forEach(el => {
const style = window.getComputedStyle(el);
const fontSize = parseFloat(style.fontSize);
if (fontSize > 0 && fontSize < 12 && el.textContent.trim()) {
el.style.backgroundColor = 'yellow';
el.style.border = '1px solid orange';
}
});
});
const screenshotPath = path.join(this.screenshotsDir, `${pageInfo.name.toLowerCase()}-small-text.png`);
await this.safeScreenshot({ path: screenshotPath, fullPage: true });
console.log(` 📸 Screenshot saved: ${screenshotPath}`);
}
}
// Check touch target sizes for mobile
if (this.viewport === 'mobile') {
const hasSmallTouchTargets = await this.page.evaluate(() => {
const clickables = document.querySelectorAll('button, a, input, select, textarea');
for (const el of clickables) {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0 && (rect.width < 44 || rect.height < 44)) {
return true;
}
}
return false;
});
if (hasSmallTouchTargets) {
this.results.warnings++;
this.results.issues.push(`⚠️ ${pageInfo.name}: Touch targets too small (< 44px)`);
// Highlight small touch targets and take screenshot if not in CI
if (!process.env.CI) {
await this.page.evaluate(() => {
const clickables = document.querySelectorAll('button, a, input, select, textarea');
clickables.forEach(el => {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0 && (rect.width < 44 || rect.height < 44)) {
el.style.outline = '2px dashed blue';
el.style.outlineOffset = '2px';
}
});
});
const screenshotPath = path.join(this.screenshotsDir, `${pageInfo.name.toLowerCase()}-small-touch-targets.png`);
await this.safeScreenshot({ path: screenshotPath, fullPage: true });
console.log(` 📸 Screenshot saved: ${screenshotPath}`);
}
}
}
this.results.passed++;
console.log(`✅ ${pageInfo.name} passed basic responsive checks`);
// Always capture screenshot for BenchmarkResults page
if (pageInfo.name === 'BenchmarkResults' && !process.env.CI) {
const screenshotPath = path.join(this.screenshotsDir, `benchmarkresults-page.png`);
await this.safeScreenshot({ path: screenshotPath, fullPage: true });
console.log(` 📸 Screenshot saved: ${screenshotPath}`);
}
} catch (error) {
this.results.failed++;
this.results.issues.push(`❌ ${pageInfo.name}: ${error.message}`);
console.error(`❌ Error testing ${pageInfo.name}:`, error.message);
}
}
async checkForOverlaps() {
return await this.page.evaluate(() => {
const elements = document.querySelectorAll('button, a, input, select');
const rects = [];
for (const el of elements) {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
rects.push(rect);
}
}
// Check for overlaps
for (let i = 0; i < rects.length; i++) {
for (let j = i + 1; j < rects.length; j++) {
const r1 = rects[i];
const r2 = rects[j];
const overlap = !(r1.right < r2.left ||
r1.left > r2.right ||
r1.bottom < r2.top ||
r1.top > r2.bottom);
if (overlap) {
return true;
}
}
}
return false;
});
}
async runTests() {
await this.init();
console.log(`\n📱 Running Responsive UI Tests for ${this.viewport.toUpperCase()} viewport\n`);
for (const pageInfo of TEST_PAGES) {
await this.testPage(pageInfo);
}
await this.cleanup();
this.printResults();
}
async cleanup() {
if (this.browser) {
await this.browser.close();
}
}
/**
* Compare the warnings produced by this run against the checked-in
* baseline (responsive_baseline.json). Three outcomes:
*
* 1. Issues match the baseline exactly → return null (test passes).
* 2. New issues appeared that aren't in the baseline → REGRESSION.
* Caller should fail the test loudly so a PR can't introduce a
* new responsive bug without notice.
* 3. Baseline issues are no longer present → BASELINE STALE.
* Caller should fail the test so the contributor who fixed the
* bug is forced to remove the entry; this prevents the baseline
* from silently masking a future regression.
*
* Returning a structured result so the caller can format messaging.
*/
diffAgainstBaseline() {
const baselinePath = path.join(__dirname, 'responsive_baseline.json');
let baseline;
try {
baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
} catch (err) {
return {
error: `Could not read responsive_baseline.json: ${err.message}`,
};
}
const expected = (baseline[this.viewport] || []).slice().sort();
// Strip the leading "⚠️ " emoji+space the test prepends so the JSON
// file stays plain ASCII and easier to diff.
const actual = this.results.issues
.map((issue) => issue.replace(/^⚠️\s+/u, ''))
.slice()
.sort();
const newRegressions = actual.filter((i) => !expected.includes(i));
const fixedIssues = expected.filter((i) => !actual.includes(i));
return { newRegressions, fixedIssues };
}
printResults() {
console.log('\n' + '='.repeat(50));
console.log('RESPONSIVE UI TEST RESULTS');
console.log('='.repeat(50));
console.log(`Viewport: ${this.viewport.toUpperCase()}`);
console.log(`✅ ${this.results.passed} passed`);
console.log(`❌ ${this.results.failed} failed`);
console.log(`⚠️ ${this.results.warnings} warnings`);
if (this.results.issues.length > 0) {
console.log('\nIMPROVEMENT SUGGESTIONS:');
this.results.issues.forEach(issue => {
console.log(issue);
});
}
console.log('='.repeat(50));
// Page-level test failures (e.g. nav errors) always fail the run.
if (this.results.failed > 0) {
process.exit(1);
}
// Compare warnings against the checked-in baseline. New responsive
// bugs fail the run; old bugs that are now fixed also fail so the
// baseline must be kept honest.
const diff = this.diffAgainstBaseline();
if (diff.error) {
console.error(`\n❌ Baseline check failed: ${diff.error}`);
process.exit(1);
}
if (diff.newRegressions.length > 0) {
console.error(
'\n❌ NEW responsive issues not in responsive_baseline.json ' +
`(${this.viewport}):`
);
diff.newRegressions.forEach((i) => console.error(` + ${i}`));
console.error(
'\nIf this is an intentional change, add the lines above to ' +
`responsive_baseline.json under "${this.viewport}". ` +
'Otherwise the PR is introducing a responsive regression.'
);
process.exit(1);
}
// We *warn* but don't fail when baseline entries don't reproduce in
// this run. Some warnings (notably touch-target detection on dynamic
// content) are intermittent — failing on a missing one would be flaky.
// If a maintainer has actually fixed an issue, they can confirm by
// running the test a few times and then trim responsive_baseline.json
// manually.
if (diff.fixedIssues.length > 0) {
console.warn(
`\n️ Some baseline entries did not reproduce this run ` +
`(${this.viewport}):`
);
diff.fixedIssues.forEach((i) => console.warn(` - ${i}`));
console.warn(
`\nIf you've fixed these issues, please re-run the test 3+ ` +
'times and remove the entries above from responsive_baseline.json. ' +
"(Not failing the run because some warnings are intermittent.)"
);
}
console.log(
`\n✅ Responsive issues within baseline (${this.viewport}): ` +
`${this.results.warnings} warning(s) this run, no regressions.`
);
}
}
// Main execution
async function main() {
const viewport = process.argv[2] || 'desktop';
if (!VIEWPORTS[viewport]) {
console.error(`Invalid viewport: ${viewport}. Use: mobile, tablet, or desktop`);
process.exit(1);
}
const tester = new ResponsiveUITester(viewport);
await tester.runTests();
}
// Run if executed directly
if (require.main === module) {
main().catch(error => {
console.error('Test execution failed:', error);
process.exit(1);
});
}
module.exports = { ResponsiveUITester };