#!/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 countCanvasPixels(canvas) { const context = canvas.getContext('2d', { willReadFrequently: true }); if (!context || !canvas.width || !canvas.height) return { inkPixels: 0, colorPixels: 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; let color = 0; for (let i = 0; i < pixels.length; i += 4) { const alpha = pixels[i + 3]; if (alpha > 10) { ink += 1; const r = pixels[i]; const g = pixels[i + 1]; const b = pixels[i + 2]; const channelSpread = Math.max(r, g, b) - Math.min(r, g, b); if (alpha > 24 && channelSpread > 18) color += 1; } } return { inkPixels: ink, colorPixels: color }; } function datasetStats(chart) { if (!chart || !chart.data || !Array.isArray(chart.data.datasets)) { return { dataPoints: 0, nonZeroPoints: 0, min: null, max: null }; } const values = []; chart.data.datasets.forEach((dataset) => { const data = Array.isArray(dataset.data) ? dataset.data : []; data.forEach((value) => { const number = Number(value); if (value !== null && value !== undefined && Number.isFinite(number)) values.push(number); }); }); return { dataPoints: values.length, nonZeroPoints: values.filter(value => Math.abs(value) > 1e-9).length, min: values.length ? Math.min(...values) : null, max: values.length ? Math.max(...values) : null, }; } 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); const pixels = countCanvasPixels(canvas); const stats = datasetStats(chart); return { id, exists: true, cssWidth: Math.round(rect.width), cssHeight: Math.round(rect.height), width: canvas.width, height: canvas.height, inkPixels: pixels.inkPixels, colorPixels: pixels.colorPixels, hasChart: Boolean(chart), datasetCount: chart && chart.data && Array.isArray(chart.data.datasets) ? chart.data.datasets.length : 0, dataPoints: stats.dataPoints, nonZeroPoints: stats.nonZeroPoints, min: stats.min, max: stats.max, 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.nonZeroPoints <= 0) { failures.push(`${item.id} has no non-zero dataset values`); } if (item.visibleElements <= 0) failures.push(`${item.id} has no visible chart elements`); if (item.inkPixels <= 900) failures.push(`${item.id} canvas appears blank`); if (item.colorPixels <= 40) failures.push(`${item.id} has no colored chart marks`); } 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},nonzero=${item.nonZeroPoints || 0},ink=${item.inkPixels || 0},color=${item.colorPixels || 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); });