322 lines
11 KiB
JavaScript
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);
|
|
});
|