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
591 lines
25 KiB
JavaScript
591 lines
25 KiB
JavaScript
/**
|
||
* 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 };
|