#!/usr/bin/env node /* * Responsive overflow guard for Flask routes. * * Usage: * scripts/check_responsive_overflow.sh --base-url http://127.0.0.1:5003 * scripts/check_responsive_overflow.sh --route /daily_sales --route /edm */ const fs = require('fs'); const os = require('os'); const path = require('path'); const Module = require('module'); const DEFAULT_BASE_URL = 'http://127.0.0.1:5003'; const DEFAULT_ROUTES = [ '/', '/edm', '/festival', '/mothers_day', '/valentine_520', '/labor_day', '/sales_analysis', '/daily_sales', '/monthly_summary_analysis', '/growth_analysis', '/ai_recommend', '/ai_history', '/ai_intelligence', '/auto_import', '/vendor-stockout/', '/vendor-stockout/import', '/vendor-stockout/list', '/vendor-stockout/vendor-management', '/vendor-stockout/send-email', '/vendor-stockout/history', '/settings', '/system_settings', '/ai_automation_smoke', '/logs', '/user_management', '/change_password', '/login_history', '/pchome_crawler', '/price_comparison', '/trends', '/notification_templates', '/brand_assets', '/market_intel', '/market_intel/campaigns', '/market_intel/products', '/market_intel/matches', '/market_intel/opportunities', '/code-review/', '/cicd', '/observability/overview', '/observability/agent_orchestration', '/observability/business_intel', '/observability/host_health', '/observability/ai_calls', '/observability/budget', '/observability/promotion_review', '/observability/rag_queries', '/observability/quality_trend', '/observability/ppt_audit_history', ]; const DEFAULT_VIEWPORTS = [ { name: 'desktop-1440', width: 1440, height: 950 }, { name: 'tablet-630', width: 630, height: 968 }, { name: 'mobile-390', width: 390, height: 844 }, ]; const LOCAL_SCROLL_SELECTORS = [ '.table-responsive', '.chart-responsive', '.obs-table-shell', '.agent-table-shell', '.biz-table-shell', '.runtime-table-shell', '.calls-table-shell', '.gov-table-shell', '.gate-table-shell', '.rag-table-shell', '.qa-table-shell', '.quality-table-shell', '.ppt-table-shell', '[class*="-table-shell"]', '.obs-chart-frame', '.chart-frame', '.chart-container', '.daily-calendar', '.campaign-switcher', '.campaign-filterbar', '.campaign-table-wrap', '.dashboard-table-wrap', '.dashboard-segmented', '.momo-nav', '.momo-scroll', ].join(','); function parseArgs(argv) { const options = { baseUrl: DEFAULT_BASE_URL, routes: [], viewports: DEFAULT_VIEWPORTS, timeoutMs: 30000, settleMs: 350, maxOverflow: 1, screenshotDir: '', screenshotAll: false, json: false, }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === '--base-url') { options.baseUrl = argv[++i]; } else if (arg === '--route') { options.routes.push(argv[++i]); } else if (arg === '--routes') { options.routes.push(...argv[++i].split(',').map((item) => item.trim()).filter(Boolean)); } else if (arg === '--viewport') { const [name, size] = argv[++i].split('='); const [width, height] = size.split('x').map((value) => parseInt(value, 10)); options.viewports.push({ name, width, height }); } else if (arg === '--clear-default-viewports') { options.viewports = []; } else if (arg === '--timeout') { options.timeoutMs = parseInt(argv[++i], 10) * 1000; } else if (arg === '--settle-ms') { options.settleMs = parseInt(argv[++i], 10); } else if (arg === '--max-overflow') { options.maxOverflow = parseInt(argv[++i], 10); } else if (arg === '--screenshot-dir') { options.screenshotDir = argv[++i]; } else if (arg === '--screenshot-all') { options.screenshotAll = true; } else if (arg === '--json') { options.json = true; } else if (arg === '--help' || arg === '-h') { printHelp(); process.exit(0); } else { throw new Error(`Unknown argument: ${arg}`); } } if (options.routes.length === 0) { options.routes = DEFAULT_ROUTES; } if (options.viewports.length === 0) { throw new Error('At least one viewport is required.'); } return options; } function printHelp() { console.log(`Responsive overflow guard Options: --base-url URL Base URL, default ${DEFAULT_BASE_URL} --route PATH Add one route, can be repeated --routes A,B,C Add comma-separated routes --viewport name=WxH Add a viewport --clear-default-viewports Use only custom --viewport entries --timeout SEC Navigation timeout, default 30 --settle-ms MS Fixed post-DOM layout settle wait, default 350 --max-overflow PX Allowed body overflow, default 1 --screenshot-dir DIR Save failure screenshots --screenshot-all Save screenshots for passing routes too --json Print JSON summary `); } function requirePlaywright() { try { return require('playwright'); } catch (error) { const candidates = [ process.env.PLAYWRIGHT_NODE_MODULE_DIR, process.env.NODE_PATH, path.join(os.homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'), ].filter(Boolean); for (const candidate of candidates) { const packageJson = path.join(candidate, 'playwright', 'package.json'); if (fs.existsSync(packageJson)) { const scopedRequire = Module.createRequire(packageJson); return scopedRequire('playwright'); } } throw new Error( 'Cannot load playwright. Set NODE_PATH or PLAYWRIGHT_NODE_MODULE_DIR to a node_modules directory containing playwright.' ); } } function findChromeExecutable() { const candidates = [ process.env.RESPONSIVE_CHROME_PATH, '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', ].filter(Boolean); return candidates.find((candidate) => fs.existsSync(candidate)) || ''; } function routeToUrl(baseUrl, route) { return new URL(route, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString(); } function safeName(input) { return input.replace(/[^a-z0-9]+/gi, '_').replace(/^_+|_+$/g, '').toLowerCase() || 'root'; } async function collectMetrics(page, maxOverflow) { return page.evaluate( ({ localScrollSelectors, maxOverflowPx }) => { const root = document.documentElement; const body = document.body; const width = window.innerWidth; const rootOverflow = Math.max(0, root.scrollWidth - width); const bodyOverflow = Math.max(0, body.scrollWidth - width); const overflow = Math.max(rootOverflow, bodyOverflow); const localScroll = Array.from(document.querySelectorAll(localScrollSelectors)) .map((el) => ({ selector: el.className ? `.${String(el.className).trim().split(/\s+/).join('.')}` : el.tagName.toLowerCase(), clientWidth: el.clientWidth, scrollWidth: el.scrollWidth, })) .filter((item) => item.scrollWidth > item.clientWidth + maxOverflowPx); const offenders = []; for (const el of document.querySelectorAll('body *')) { const rect = el.getBoundingClientRect(); if (rect.width <= 0 || rect.right <= width + maxOverflowPx) { continue; } if (el.closest(localScrollSelectors)) { continue; } offenders.push({ tag: el.tagName.toLowerCase(), id: el.id || '', className: String(el.className || '').slice(0, 120), right: Math.round(rect.right), width: Math.round(rect.width), text: String(el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 90), }); if (offenders.length >= 8) { break; } } return { finalUrl: location.href, title: document.title, innerWidth: width, rootScrollWidth: root.scrollWidth, bodyScrollWidth: body.scrollWidth, overflow, localScroll, offenders, }; }, { localScrollSelectors: LOCAL_SCROLL_SELECTORS, maxOverflowPx: maxOverflow } ); } async function main() { const options = parseArgs(process.argv.slice(2)); const { chromium } = requirePlaywright(); const chromePath = findChromeExecutable(); const launchOptions = { headless: true }; if (chromePath) { launchOptions.executablePath = chromePath; } if (options.screenshotDir) { fs.mkdirSync(options.screenshotDir, { recursive: true }); } const browser = await chromium.launch(launchOptions); const results = []; const pages = new Map(); try { for (const viewport of options.viewports) { const page = await browser.newPage({ viewport: { width: viewport.width, height: viewport.height }, deviceScaleFactor: 1, }); pages.set(viewport.name, page); } for (const route of options.routes) { for (const viewport of options.viewports) { const page = pages.get(viewport.name); const url = routeToUrl(options.baseUrl, route); let status = 0; let error = ''; let metrics = null; try { const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: options.timeoutMs }); await page.evaluate(() => document.fonts && document.fonts.ready).catch(() => {}); if (options.settleMs > 0) { await page.waitForTimeout(options.settleMs); } status = response ? response.status() : 0; metrics = await collectMetrics(page, options.maxOverflow); if (new URL(metrics.finalUrl).pathname === '/login') { error = 'redirected to /login; run local QA with DISABLE_LOGIN=true or log in first'; } else if (status >= 400) { error = `HTTP ${status}`; } else if (metrics.overflow > options.maxOverflow) { error = `body horizontal overflow ${metrics.overflow}px`; } else if (metrics.offenders.length > 0) { error = `visual overflow offenders ${metrics.offenders.length}`; } } catch (err) { error = err.message || String(err); } const passed = !error; const result = { route, viewport: viewport.name, status, passed, error, metrics }; results.push(result); if ((options.screenshotAll || !passed) && options.screenshotDir) { const file = `${safeName(route)}_${safeName(viewport.name)}.png`; await page.screenshot({ path: path.join(options.screenshotDir, file), fullPage: false }); } } } } finally { await Promise.all(Array.from(pages.values()).map((page) => page.close().catch(() => {}))); await browser.close(); } if (options.json) { console.log(JSON.stringify(results, null, 2)); } else { for (const result of results) { const status = result.passed ? 'PASS' : 'FAIL'; const overflow = result.metrics ? `${result.metrics.overflow}px` : 'n/a'; const localScroll = result.metrics ? result.metrics.localScroll.length : 0; console.log(`${status} ${result.viewport} ${result.route} overflow=${overflow} local_scroll=${localScroll}${result.error ? ` error=${result.error}` : ''}`); if (!result.passed && result.metrics && result.metrics.offenders.length) { for (const offender of result.metrics.offenders) { console.log(` offender ${offender.tag}.${offender.className} right=${offender.right} text="${offender.text}"`); } } } } const failed = results.filter((result) => !result.passed); if (failed.length > 0) { process.exitCode = 1; } } main().catch((error) => { console.error(error.message || error); process.exit(1); });