Files
ewoooc/scripts/check_responsive_overflow.js
OoO b2ab03f0d0
All checks were successful
CD Pipeline / deploy (push) Successful in 56s
入庫 responsive overflow guard 腳本
2026-05-13 12:13:09 +08:00

308 lines
9.8 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',
'.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`;
}
} 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);
});