325 lines
10 KiB
JavaScript
Executable File
325 lines
10 KiB
JavaScript
Executable File
#!/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',
|
|
'/sales_analysis',
|
|
'/daily_sales',
|
|
'/monthly_summary_analysis',
|
|
'/growth_analysis',
|
|
'/ai_recommend',
|
|
'/auto_import',
|
|
'/vendor-stockout/',
|
|
'/vendor-stockout/import',
|
|
'/vendor-stockout/list',
|
|
'/settings',
|
|
'/logs',
|
|
'/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,
|
|
maxOverflow: 1,
|
|
screenshotDir: '',
|
|
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 === '--max-overflow') {
|
|
options.maxOverflow = parseInt(argv[++i], 10);
|
|
} else if (arg === '--screenshot-dir') {
|
|
options.screenshotDir = argv[++i];
|
|
} 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
|
|
--max-overflow PX Allowed body overflow, default 1
|
|
--screenshot-dir DIR Save failure screenshots
|
|
--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 = [];
|
|
|
|
try {
|
|
for (const route of options.routes) {
|
|
for (const viewport of options.viewports) {
|
|
const page = await browser.newPage({
|
|
viewport: { width: viewport.width, height: viewport.height },
|
|
deviceScaleFactor: 1,
|
|
});
|
|
const url = routeToUrl(options.baseUrl, route);
|
|
let status = 0;
|
|
let error = '';
|
|
let metrics = null;
|
|
|
|
try {
|
|
const response = await page.goto(url, { waitUntil: 'networkidle', timeout: options.timeoutMs });
|
|
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 (!passed && options.screenshotDir) {
|
|
const file = `${safeName(route)}_${safeName(viewport.name)}.png`;
|
|
await page.screenshot({ path: path.join(options.screenshotDir, file), fullPage: false });
|
|
}
|
|
|
|
await page.close();
|
|
}
|
|
}
|
|
} finally {
|
|
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);
|
|
});
|