This commit is contained in:
@@ -122,6 +122,7 @@
|
||||
- V10.235 補 PPT 視覺 QA stale recovery:背景狀態寫入 worker PID;若部署 reload 後舊 PID 已不存在,`/observability/ppt_audit/vision_status` 會自動把 running 轉為可診斷 error 並允許重新排入,避免人工清 runtime state。
|
||||
- Phase 58 candidate queue writer post-write smoke:新增 `services/market_intel/candidate_queue_writer_postwrite_smoke.py`、POST `/api/market_intel/manual_sample_review/candidate_queue_writer_postwrite_smoke` 與 UI smoke 按鈕,依 transaction preview 的 dedupe key 只讀查詢 `market_alert_review_queue`,讓 CLI 真寫入後可驗證 row 是否存在;頁面預設 execute=false 不連 DB、不寫 queue、不 commit、不掛 scheduler;版本同步至 V10.236。
|
||||
- Phase 59 candidate queue writer operator drill:新增 `services/market_intel/candidate_queue_writer_operator_drill.py`、POST `/api/market_intel/manual_sample_review/candidate_queue_writer_operator_drill` 與 UI drill 按鈕,組裝 reviewed sample、備份、read-only preflight、CLI writer、post-write smoke 的操作員順序;API/UI 不讀 approval token、不執行 CLI、不連 DB、不寫 queue、不 commit、不掛 scheduler;版本同步至 V10.237。
|
||||
- V10.238 補業績圖表 runtime QA 與分析 tabs 窄版修正:新增 `quick_review --sales-charts` 檢查 `/daily_sales`、`/growth_analysis` 的 Chart.js ready、可繪製資料集與 canvas 非空白;同時把分析報表 tabs 手機版改為自適應 grid,避免 Metabase/Grist 外部連結超出右側。
|
||||
- Schema smoke:`tests/test_market_intel_skeleton.py` 檢查 `Base.metadata` 內含 ADR-035 八張 `market_*` tables。
|
||||
- Desktop UI QA:本機只註冊 `market_intel_bp` 的 Flask harness 載入 `/market_intel`,確認 Phase 15、候選預覽、writer preview、安全 flags、點陣暖紙視覺正常,console error 0。
|
||||
- API QA:`/api/market_intel/schema_smoke` 通過 7 張表與 `market_platforms` 必要欄位檢查;`/api/market_intel/platform_seed_writer_plan` 回傳 4 筆 dry-run upsert preview,`writes_executed=false`,四平台皆 `blocked_dry_run_only`。
|
||||
|
||||
@@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.237"
|
||||
SYSTEM_VERSION = "V10.238"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
321
scripts/check_sales_charts_runtime.js
Normal file
321
scripts/check_sales_charts_runtime.js
Normal file
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* Runtime chart guard for the sales analytics pages.
|
||||
*
|
||||
* It catches the failure mode where the page renders a chart shell but Chart.js
|
||||
* never draws the real series. The check intentionally inspects both the
|
||||
* Chart.js runtime objects and the actual canvas pixels.
|
||||
*/
|
||||
|
||||
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_TIMEOUT_MS = 30000;
|
||||
const DEFAULT_SETTLE_MS = 1800;
|
||||
|
||||
const ROUTE_CONTRACTS = {
|
||||
'/daily_sales': {
|
||||
readyDataset: 'dailyCharts',
|
||||
expectedCanvases: ['trendChart', 'dodChart', 'wowChart', 'top10Chart'],
|
||||
},
|
||||
'/growth_analysis': {
|
||||
readyDataset: 'growthCharts',
|
||||
expectedCanvases: ['revenueChart', 'momChart', 'aovChart', 'marginChart'],
|
||||
},
|
||||
};
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
baseUrl: DEFAULT_BASE_URL,
|
||||
routes: [],
|
||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
settleMs: DEFAULT_SETTLE_MS,
|
||||
json: false,
|
||||
screenshotDir: '',
|
||||
};
|
||||
|
||||
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 === '--timeout') {
|
||||
options.timeoutMs = Number(argv[++i]) * 1000;
|
||||
} else if (arg === '--settle-ms') {
|
||||
options.settleMs = Number(argv[++i]);
|
||||
} 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 = Object.keys(ROUTE_CONTRACTS);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Sales charts runtime 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
|
||||
--timeout SEC Navigation timeout, default 30
|
||||
--settle-ms MS Post-ready settle wait, default 1800
|
||||
--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)) {
|
||||
return Module.createRequire(packageJson)('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.SALES_CHARTS_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 collectChartMetrics(page, contract) {
|
||||
return page.evaluate(({ expectedCanvases, readyDataset }) => {
|
||||
function countInkPixels(canvas) {
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!context || !canvas.width || !canvas.height) return 0;
|
||||
|
||||
const sampleWidth = Math.min(canvas.width, 900);
|
||||
const sampleHeight = Math.min(canvas.height, 520);
|
||||
const pixels = context.getImageData(0, 0, sampleWidth, sampleHeight).data;
|
||||
let ink = 0;
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
const alpha = pixels[i + 3];
|
||||
if (alpha > 10) {
|
||||
ink += 1;
|
||||
if (ink > 250) break;
|
||||
}
|
||||
}
|
||||
return ink;
|
||||
}
|
||||
|
||||
function datasetPointCount(chart) {
|
||||
if (!chart || !chart.data || !Array.isArray(chart.data.datasets)) return 0;
|
||||
return chart.data.datasets.reduce((total, dataset) => {
|
||||
const data = Array.isArray(dataset.data) ? dataset.data : [];
|
||||
return total + data.filter((value) => value !== null && value !== undefined && Number.isFinite(Number(value))).length;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function visibleElementCount(chart) {
|
||||
if (!chart || typeof chart.getSortedVisibleDatasetMetas !== 'function') return 0;
|
||||
return chart.getSortedVisibleDatasetMetas().reduce((total, meta) => {
|
||||
if (!meta || !Array.isArray(meta.data)) return total;
|
||||
return total + meta.data.filter((item) => {
|
||||
if (!item) return false;
|
||||
const props = typeof item.getProps === 'function'
|
||||
? item.getProps(['x', 'y', 'base'], true)
|
||||
: item;
|
||||
return Number.isFinite(Number(props.x)) && Number.isFinite(Number(props.y));
|
||||
}).length;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const chartGetter = window.Chart && typeof window.Chart.getChart === 'function'
|
||||
? window.Chart.getChart.bind(window.Chart)
|
||||
: () => null;
|
||||
|
||||
const canvases = expectedCanvases.map((id) => {
|
||||
const canvas = document.getElementById(id);
|
||||
if (!canvas) return { id, exists: false };
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const chart = chartGetter(canvas);
|
||||
return {
|
||||
id,
|
||||
exists: true,
|
||||
cssWidth: Math.round(rect.width),
|
||||
cssHeight: Math.round(rect.height),
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
inkPixels: countInkPixels(canvas),
|
||||
hasChart: Boolean(chart),
|
||||
datasetCount: chart && chart.data && Array.isArray(chart.data.datasets) ? chart.data.datasets.length : 0,
|
||||
dataPoints: datasetPointCount(chart),
|
||||
visibleElements: visibleElementCount(chart),
|
||||
ariaHidden: canvas.getAttribute('aria-hidden') || '',
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
finalUrl: location.href,
|
||||
title: document.title,
|
||||
login: Boolean(document.querySelector('form[action="/login"]')),
|
||||
readyState: document.documentElement.dataset[readyDataset] || '',
|
||||
chartVersion: window.Chart && window.Chart.version ? window.Chart.version : '',
|
||||
canvases,
|
||||
};
|
||||
}, { expectedCanvases: contract.expectedCanvases, readyDataset: contract.readyDataset });
|
||||
}
|
||||
|
||||
function evaluateMetrics(route, status, metrics, consoleErrors) {
|
||||
const failures = [];
|
||||
if (!status || status >= 400) failures.push(`HTTP ${status || 0}`);
|
||||
if (metrics.login) failures.push('redirected to login');
|
||||
if (metrics.readyState !== 'ready') failures.push(`chart boot state is ${metrics.readyState || 'missing'}`);
|
||||
if (!metrics.chartVersion) failures.push('Chart.js runtime missing');
|
||||
for (const item of metrics.canvases) {
|
||||
if (!item.exists) {
|
||||
failures.push(`${item.id} missing`);
|
||||
continue;
|
||||
}
|
||||
if (item.cssWidth < 220 || item.cssHeight < 180) {
|
||||
failures.push(`${item.id} too small ${item.cssWidth}x${item.cssHeight}`);
|
||||
}
|
||||
if (!item.hasChart) failures.push(`${item.id} has no Chart.js instance`);
|
||||
if (item.datasetCount <= 0 || item.dataPoints <= 0) {
|
||||
failures.push(`${item.id} has no drawable dataset`);
|
||||
}
|
||||
if (item.visibleElements <= 0) failures.push(`${item.id} has no visible chart elements`);
|
||||
if (item.inkPixels <= 250) failures.push(`${item.id} canvas appears blank`);
|
||||
}
|
||||
for (const err of consoleErrors) {
|
||||
failures.push(`console ${err}`);
|
||||
}
|
||||
return {
|
||||
route,
|
||||
status,
|
||||
passed: failures.length === 0,
|
||||
failures,
|
||||
metrics,
|
||||
};
|
||||
}
|
||||
|
||||
async function inspectRoute(page, options, route) {
|
||||
const contract = ROUTE_CONTRACTS[route];
|
||||
if (!contract) throw new Error(`No chart contract for route: ${route}`);
|
||||
|
||||
const consoleErrors = [];
|
||||
const onConsole = (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text());
|
||||
};
|
||||
const onPageError = (error) => consoleErrors.push(error.message || String(error));
|
||||
page.on('console', onConsole);
|
||||
page.on('pageerror', onPageError);
|
||||
|
||||
try {
|
||||
const response = await page.goto(routeToUrl(options.baseUrl, route), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: options.timeoutMs,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
(readyDataset) => document.documentElement.dataset[readyDataset] === 'ready',
|
||||
contract.readyDataset,
|
||||
{ timeout: Math.min(options.timeoutMs, 15000) },
|
||||
).catch(() => null);
|
||||
if (options.settleMs > 0) await page.waitForTimeout(options.settleMs);
|
||||
|
||||
const metrics = await collectChartMetrics(page, contract);
|
||||
return evaluateMetrics(route, response ? response.status() : 0, metrics, consoleErrors);
|
||||
} finally {
|
||||
page.off('console', onConsole);
|
||||
page.off('pageerror', onPageError);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const { chromium } = requirePlaywright();
|
||||
const launchOptions = { headless: true };
|
||||
const chromePath = findChromeExecutable();
|
||||
if (chromePath) launchOptions.executablePath = chromePath;
|
||||
|
||||
if (options.screenshotDir) fs.mkdirSync(options.screenshotDir, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch(launchOptions);
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
const results = [];
|
||||
|
||||
try {
|
||||
for (const route of options.routes) {
|
||||
const result = await inspectRoute(page, options, route);
|
||||
results.push(result);
|
||||
if (!result.passed && options.screenshotDir) {
|
||||
await page.screenshot({
|
||||
path: path.join(options.screenshotDir, `${safeName(route)}.png`),
|
||||
fullPage: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
} else {
|
||||
for (const result of results) {
|
||||
const canvasSummary = result.metrics.canvases
|
||||
.map((item) => `${item.id}:chart=${item.hasChart ? 'yes' : 'no'},points=${item.dataPoints || 0},ink=${item.inkPixels || 0}`)
|
||||
.join(' ');
|
||||
console.log(`${result.passed ? 'PASS' : 'FAIL'} ${result.route} status=${result.status} ${canvasSummary}`);
|
||||
for (const failure of result.failures) {
|
||||
console.log(` ${failure}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (results.some((result) => !result.passed)) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message || error);
|
||||
process.exit(1);
|
||||
});
|
||||
20
scripts/check_sales_charts_runtime.sh
Executable file
20
scripts/check_sales_charts_runtime.sh
Executable file
@@ -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_sales_charts_runtime.js" "$@"
|
||||
@@ -22,6 +22,7 @@ OBSERVABILITY_QA_SUITE="$PROJECT_ROOT/scripts/check_observability_suite.sh"
|
||||
OBSERVABILITY_CSS_SYNC="$PROJECT_ROOT/scripts/sync_observability_css.py"
|
||||
OBSERVABILITY_VISUAL_CONTRACT="$PROJECT_ROOT/scripts/check_observability_visual_contract.sh"
|
||||
RESPONSIVE_OVERFLOW_GUARD="$PROJECT_ROOT/scripts/check_responsive_overflow.sh"
|
||||
SALES_CHARTS_GUARD="$PROJECT_ROOT/scripts/check_sales_charts_runtime.sh"
|
||||
REVIEW_REPORT_HINT=0
|
||||
|
||||
# 顯示標題
|
||||
@@ -109,6 +110,16 @@ run_responsive_overflow_guard() {
|
||||
bash "$RESPONSIVE_OVERFLOW_GUARD" "$@"
|
||||
}
|
||||
|
||||
run_sales_charts_guard() {
|
||||
if [ ! -f "$SALES_CHARTS_GUARD" ]; then
|
||||
echo -e "${RED}❌ 業績圖表 runtime guard 不存在: $SALES_CHARTS_GUARD${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}📈 開始業績分析圖表 runtime 檢查...${NC}"
|
||||
bash "$SALES_CHARTS_GUARD" "$@"
|
||||
}
|
||||
|
||||
# 非互動入口:給部署腳本、CI、或 Codex session 直接呼叫。
|
||||
if [ $# -gt 0 ]; then
|
||||
case "$1" in
|
||||
@@ -147,6 +158,11 @@ if [ $# -gt 0 ]; then
|
||||
run_responsive_overflow_guard "$@"
|
||||
exit $?
|
||||
;;
|
||||
--sales-charts)
|
||||
shift
|
||||
run_sales_charts_guard "$@"
|
||||
exit $?
|
||||
;;
|
||||
--observability-help)
|
||||
cat <<'EOF'
|
||||
AI observability quick-review flags:
|
||||
@@ -164,6 +180,8 @@ AI observability quick-review flags:
|
||||
Run rendered typography, surface, radius, contrast, and mobile density checks.
|
||||
--responsive-overflow [--base-url URL] [--route PATH ...]
|
||||
Run desktop/tablet/mobile body horizontal overflow checks for Flask routes.
|
||||
--sales-charts [--base-url URL] [--timeout SEC]
|
||||
Run Chart.js runtime and nonblank canvas checks for /daily_sales and /growth_analysis.
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
@@ -184,8 +202,9 @@ if [ $# -eq 0 ]; then
|
||||
echo "9) 同步 AI觀測台 CSS static mirror"
|
||||
echo "10) 全頁 responsive overflow 檢查"
|
||||
echo "11) AI觀測台渲染後視覺契約檢查"
|
||||
echo "12) 業績分析圖表 runtime 檢查"
|
||||
echo ""
|
||||
read -p "請輸入選項 (1-11): " choice
|
||||
read -p "請輸入選項 (1-12): " choice
|
||||
|
||||
case $choice in
|
||||
1)
|
||||
@@ -238,6 +257,9 @@ if [ $# -eq 0 ]; then
|
||||
11)
|
||||
run_observability_visual_contract
|
||||
;;
|
||||
12)
|
||||
run_sales_charts_guard
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}❌ 無效選項${NC}"
|
||||
exit 1
|
||||
|
||||
@@ -58,9 +58,9 @@
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.analysis-report-tabs {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(128px, 1fr));
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.analysis-report-tabs-spacer {
|
||||
@@ -68,6 +68,8 @@
|
||||
}
|
||||
|
||||
.analysis-report-tab {
|
||||
flex: 0 0 auto;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
padding: 0 var(--momo-space-2, 8px);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user