Files
learningcircuit--local-deep…/tests/ui_tests/test_responsive_ui_comprehensive.js
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 lines
25 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.
/**
* 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 };