From b2ab03f0d099be998da5eb43f60521ee25c87613 Mon Sep 17 00:00:00 2001 From: OoO Date: Wed, 13 May 2026 12:13:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=A5=E5=BA=AB=20responsive=20overflow=20gu?= =?UTF-8?q?ard=20=E8=85=B3=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 - scripts/check_responsive_overflow.js | 307 +++++++++++++++++++++++++++ scripts/check_responsive_overflow.sh | 20 ++ 3 files changed, 327 insertions(+), 2 deletions(-) create mode 100755 scripts/check_responsive_overflow.js create mode 100755 scripts/check_responsive_overflow.sh diff --git a/.gitignore b/.gitignore index 41e721b..c540758 100644 --- a/.gitignore +++ b/.gitignore @@ -100,8 +100,6 @@ MOMO Pro/ production_v3*/ frontend/ docker-compose.frontend.yml -scripts/check_responsive_overflow.js -scripts/check_responsive_overflow.sh # 測試與覆蓋率報告 .pytest_cache/ diff --git a/scripts/check_responsive_overflow.js b/scripts/check_responsive_overflow.js new file mode 100755 index 0000000..063bee2 --- /dev/null +++ b/scripts/check_responsive_overflow.js @@ -0,0 +1,307 @@ +#!/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); +}); diff --git a/scripts/check_responsive_overflow.sh b/scripts/check_responsive_overflow.sh new file mode 100755 index 0000000..ca53242 --- /dev/null +++ b/scripts/check_responsive_overflow.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUNDLED_NODE="$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node" +BUNDLED_NODE_MODULES="$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules" + +if [ -z "${NODE_BIN:-}" ] && [ -x "$BUNDLED_NODE" ]; then + NODE_BIN="$BUNDLED_NODE" +fi + +if [ -z "${NODE_BIN:-}" ]; then + NODE_BIN="node" +fi + +if [ -d "$BUNDLED_NODE_MODULES" ] && [ -z "${NODE_PATH:-}" ]; then + export NODE_PATH="$BUNDLED_NODE_MODULES" +fi + +exec "$NODE_BIN" "$PROJECT_ROOT/scripts/check_responsive_overflow.js" "$@"