Files
ewoooc/scripts/check_sales_charts_runtime.js
OoO ac5d1a2d81
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s
補業績圖表 runtime QA
2026-05-19 10:40:22 +08:00

322 lines
11 KiB
JavaScript

#!/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);
});