#!/usr/bin/env node /* * Runtime integrity guard for every internal analytics report. * * A rendered chart slot must end in exactly one trustworthy state: * a real chart with drawable pixels and data, or a visible empty state. */ 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 = 45000; const DEFAULT_SETTLE_MS = 1800; const DEFAULT_VIEWPORTS = [ { width: 1440, height: 1000 }, { width: 1024, height: 900 }, { width: 390, height: 844 }, ]; const ROUTE_CONTRACTS = [ { name: 'daily', path: '/daily_sales?month=2026-04', renderer: 'chartjs', selector: '.chart-container canvas', readyDataset: 'dailyCharts', minimumTargets: 9, screenshotTarget: '#dodChart', }, { name: 'growth', path: '/growth_analysis?start_month=2026-04&end_month=2026-04', renderer: 'chartjs', selector: '.ga-chart-card__body canvas', readyDataset: 'growthCharts', minimumTargets: 5, screenshotTarget: '#aovChart', }, { name: 'sales', path: '/sales_analysis?start_date=2026-04-01&end_date=2026-04-30', renderer: 'chartjs', selector: '.sa-chart-shell canvas', readyDataset: 'salesCharts', minimumTargets: 10, screenshotTarget: '#barChart', }, { name: 'monthly', path: '/monthly_summary_analysis?start_month=2026-04&end_month=2026-04', renderer: 'echarts', selector: '.ms-chart-card__canvas', readyDataset: 'monthlyCharts', secondaryReadyDataset: 'monthlySpecialCharts', minimumTargets: 13, screenshotTarget: '#vendorRankingChart', }, ]; function parseArgs(argv) { const options = { baseUrl: DEFAULT_BASE_URL, timeoutMs: DEFAULT_TIMEOUT_MS, settleMs: DEFAULT_SETTLE_MS, viewports: DEFAULT_VIEWPORTS, routeNames: [], screenshotDir: '', screenshotAll: false, json: false, loginPassword: process.env.ANALYSIS_QA_LOGIN_PASSWORD || '', }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--base-url') options.baseUrl = argv[++index]; else if (arg === '--timeout') options.timeoutMs = Number(argv[++index]) * 1000; else if (arg === '--settle-ms') options.settleMs = Number(argv[++index]); else if (arg === '--route') options.routeNames.push(argv[++index]); else if (arg === '--viewport') options.viewports = [parseViewport(argv[++index])]; else if (arg === '--screenshot-dir') options.screenshotDir = argv[++index]; else if (arg === '--screenshot-all') options.screenshotAll = true; else if (arg === '--json') options.json = true; else if (arg === '--help' || arg === '-h') { printHelp(); process.exit(0); } else throw new Error(`Unknown argument: ${arg}`); } return options; } function parseViewport(value) { const match = /^(\d+)x(\d+)$/.exec(value || ''); if (!match) throw new Error(`Invalid viewport: ${value}`); return { width: Number(match[1]), height: Number(match[2]) }; } function printHelp() { console.log(`Analysis chart integrity guard Options: --base-url URL Base URL, default ${DEFAULT_BASE_URL} --route NAME daily, growth, sales, or monthly --viewport WIDTHxHEIGHT Run one viewport instead of the default matrix --timeout SEC Navigation timeout, default 45 --settle-ms MS Post-ready settle wait, default 1800 --screenshot-dir DIR Save failure screenshots --screenshot-all Save passing screenshots too --json Print JSON summary Set ANALYSIS_QA_LOGIN_PASSWORD when the target requires login. `); } 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 PLAYWRIGHT_NODE_MODULE_DIR to a runtime containing playwright.'); } } function findChromeExecutable() { return [ process.env.ANALYSIS_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).find(candidate => fs.existsSync(candidate)) || ''; } function fullUrl(baseUrl, routePath) { return new URL(routePath, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString(); } function safeName(value) { return value.replace(/[^a-z0-9]+/gi, '_').replace(/^_+|_+$/g, '').toLowerCase(); } async function loginIfNeeded(page, password, timeoutMs) { const loginForm = page.locator('form:has(input[type="password"])').first(); if (!await loginForm.count()) return false; if (!password) throw new Error('Target requires login; set ANALYSIS_QA_LOGIN_PASSWORD.'); await loginForm.locator('input[type="password"]').fill(password); const username = loginForm.locator('input[name="username"], input[type="email"], input[type="text"]').first(); if (await username.count()) await username.fill(''); await Promise.all([ page.waitForLoadState('domcontentloaded', { timeout: timeoutMs }).catch(() => null), loginForm.locator('button[type="submit"], input[type="submit"]').first().click(), ]); return true; } async function primeAuthentication(page, options, contract) { await page.goto(fullUrl(options.baseUrl, contract.path), { waitUntil: 'domcontentloaded', timeout: options.timeoutMs, }); if (!await loginIfNeeded(page, options.loginPassword, options.timeoutMs)) return; // Leave login/dashboard requests behind before page-specific error capture starts. await page.goto('about:blank', { waitUntil: 'domcontentloaded', timeout: options.timeoutMs }); } async function collectMetrics(page, contract) { return page.evaluate(({ name, renderer, selector, readyDataset, secondaryReadyDataset }) => { function countPixels(canvas) { if (!canvas || !canvas.width || !canvas.height) return { inkPixels: 0, colorPixels: 0 }; const context = canvas.getContext('2d', { willReadFrequently: true }); if (!context) return { inkPixels: 0, colorPixels: 0 }; const width = Math.min(canvas.width, 900); const height = Math.min(canvas.height, 520); const pixels = context.getImageData(0, 0, width, height).data; let inkPixels = 0; let colorPixels = 0; for (let index = 0; index < pixels.length; index += 4) { const alpha = pixels[index + 3]; if (alpha <= 10) continue; inkPixels += 1; const spread = Math.max(pixels[index], pixels[index + 1], pixels[index + 2]) - Math.min(pixels[index], pixels[index + 1], pixels[index + 2]); if (alpha > 24 && spread > 18) colorPixels += 1; } return { inkPixels, colorPixels }; } function chartJsStats(canvas, chart) { const values = []; let clippedPoints = 0; let originalMaxAbs = 0; (chart && chart.data && chart.data.datasets || []).forEach(dataset => { (Array.isArray(dataset.data) ? dataset.data : []).forEach(raw => { const display = raw && typeof raw === 'object' ? (raw.y ?? raw.r ?? raw.v ?? raw.value) : raw; if (display !== null && display !== undefined && Number.isFinite(Number(display))) { values.push(Number(display)); } if (raw && typeof raw === 'object') { if (raw.clipped) clippedPoints += 1; if (Number.isFinite(Number(raw.originalValue))) { originalMaxAbs = Math.max(originalMaxAbs, Math.abs(Number(raw.originalValue))); } } }); }); let visibleElements = 0; let maxPointRadius = 0; if (chart && typeof chart.getSortedVisibleDatasetMetas === 'function') { chart.getSortedVisibleDatasetMetas().forEach(meta => { (meta.data || []).forEach(item => { if (!item) return; const props = typeof item.getProps === 'function' ? item.getProps(['x', 'y'], true) : item; if (Number.isFinite(Number(props.x)) && Number.isFinite(Number(props.y))) visibleElements += 1; maxPointRadius = Math.max(maxPointRadius, Number(item.options && item.options.radius || 0)); }); }); } const shell = canvas.closest('.chart-container, .ga-chart-card__body, .sa-chart-shell'); const empty = shell && shell.querySelector('.chart-empty-state, .ga-chart-empty, .sa-chart-empty'); return { dataPoints: values.length, nonZeroPoints: values.filter(value => Math.abs(value) > 1e-9).length, displayMaxAbs: values.length ? Math.max(...values.map(Math.abs)) : 0, originalMaxAbs, clippedPoints, visibleElements, maxPointRadius, chartLabels: Array.isArray(chart && chart.data && chart.data.labels) ? chart.data.labels.map(value => String(value)) : [], categoryTickLabels: (() => { if (!chart || !chart.scales) return []; const axis = chart.options && chart.options.indexAxis === 'y' ? chart.scales.y : chart.scales.x; return axis && Array.isArray(axis.ticks) ? axis.ticks.map(tick => String(tick.label)) : []; })(), explicitEmpty: Boolean(shell && shell.classList.contains('chart-empty-active') && empty), emptyVisible: Boolean(empty && empty.getBoundingClientRect().width > 0 && empty.getBoundingClientRect().height > 0), }; } function echartsStats(target, chart) { const option = (chart && chart.getOption ? chart.getOption() : null) || {}; const values = []; function collect(value) { if (Array.isArray(value)) value.forEach(collect); else if (value && typeof value === 'object') Object.values(value).forEach(collect); else if (typeof value === 'number' && Number.isFinite(value)) values.push(value); } (option.series || []).forEach(series => collect(series.data || [])); const titles = Array.isArray(option.title) ? option.title : [option.title]; const explicitEmpty = titles.some(title => title && title.text === '尚無可繪製資料'); const axes = Array.isArray(option.xAxis) ? option.xAxis : [option.xAxis]; return { dataPoints: values.length, nonZeroPoints: values.filter(value => Math.abs(value) > 1e-9).length, visibleElements: values.length, maxPointRadius: 0, originalMaxAbs: 0, displayMaxAbs: values.length ? Math.max(...values.map(Math.abs)) : 0, clippedPoints: 0, chartLabels: axes.flatMap(axis => axis && Array.isArray(axis.data) ? axis.data.map(value => String(value)) : []), categoryTickLabels: [], explicitEmpty, emptyVisible: explicitEmpty && target.getBoundingClientRect().height > 0, }; } function sourceHasDrawable(targetId) { if (name !== 'sales') return false; const payloadNode = document.getElementById('sales-data'); if (!payloadNode) return false; let payload; try { payload = JSON.parse(payloadNode.textContent); } catch (error) { return false; } const keyByTarget = { barChart: 'barData', categoryChart: 'categoryData', priceDistChart: 'priceDistData', monthlyChart: 'monthlyData', weeklyChart: 'weeklyData', dowChart: 'dowData', hourlyChart: 'hourlyData', }; const series = payload[keyByTarget[targetId]]; if (!series) return false; const values = Array.isArray(series.values) && series.values.length ? series.values : series.chart_values; return Array.isArray(values) && values.some(value => Number.isFinite(Number(value)) && Math.abs(Number(value)) > 1e-9); } const targets = Array.from(document.querySelectorAll(selector)).map(target => { const isCanvas = target.tagName === 'CANVAS'; const chart = renderer === 'chartjs' ? (window.Chart && window.Chart.getChart ? window.Chart.getChart(target) : null) : (window.echarts && window.echarts.getInstanceByDom ? window.echarts.getInstanceByDom(target) : null); const canvas = isCanvas ? target : target.querySelector('canvas'); const rect = target.getBoundingClientRect(); const stats = renderer === 'chartjs' ? chartJsStats(target, chart) : echartsStats(target, chart); return { id: target.id || '', cssWidth: Math.round(rect.width), cssHeight: Math.round(rect.height), hasChart: Boolean(chart), sourceHasDrawable: sourceHasDrawable(target.id || ''), renderer, ...countPixels(canvas), ...stats, }; }); const loading = document.getElementById('loadingOverlay'); return { finalUrl: location.href, title: document.title, login: Boolean(document.querySelector('form:has(input[type="password"])')), readyState: document.documentElement.dataset[readyDataset] || '', secondaryReadyState: secondaryReadyDataset ? document.documentElement.dataset[secondaryReadyDataset] || '' : '', bodyOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, loadingVisible: Boolean(loading && getComputedStyle(loading).display !== 'none'), periodLinks: Object.fromEntries(Array.from(document.querySelectorAll('[data-analysis-target]')).map(link => [ link.dataset.analysisTarget, link.getAttribute('href') || '', ])), targets, }; }, contract); } function evaluateResult(contract, viewport, 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(`primary chart state is ${metrics.readyState || 'missing'}`); if (contract.secondaryReadyDataset && metrics.secondaryReadyState !== 'ready') { failures.push(`secondary chart state is ${metrics.secondaryReadyState || 'missing'}`); } if (metrics.loadingVisible) failures.push('loading overlay remains visible'); if (metrics.bodyOverflow) failures.push('page has horizontal overflow'); if (metrics.targets.length < contract.minimumTargets) { failures.push(`only ${metrics.targets.length}/${contract.minimumTargets} chart targets rendered`); } const expectedPeriodLinks = { sales: ['start_date=2026-04-01', 'end_date=2026-04-30'], daily_sales: ['month=2026-04'], growth: ['start_month=2026-04', 'end_month=2026-04'], monthly: ['start_month=2026-04', 'end_month=2026-04'], }; Object.entries(expectedPeriodLinks).forEach(([target, fragments]) => { const href = metrics.periodLinks[target] || ''; if (fragments.some(fragment => !href.includes(fragment))) { failures.push(`${target} analysis tab lost the active period: ${href || 'missing'}`); } }); for (const item of metrics.targets) { if (item.explicitEmpty) { if (item.sourceHasDrawable) failures.push(`${item.id} hides drawable payload behind an empty state`); if (!item.emptyVisible) failures.push(`${item.id} empty state is not visible`); continue; } if (item.cssWidth < Math.min(220, viewport.width - 40) || item.cssHeight < 180) { failures.push(`${item.id} invalid size ${item.cssWidth}x${item.cssHeight}`); } if (!item.hasChart) failures.push(`${item.id} has no ${contract.renderer} instance`); if (item.dataPoints <= 0 || item.nonZeroPoints <= 0) failures.push(`${item.id} has no drawable values`); if (item.visibleElements <= 0) failures.push(`${item.id} has no visible chart elements`); if (item.inkPixels <= 700) failures.push(`${item.id} canvas appears blank`); if (item.colorPixels <= 30) failures.push(`${item.id} has no colored chart marks`); if (contract.name === 'daily' && ['dodChart', 'wowChart'].includes(item.id) && item.originalMaxAbs > 100) { if (item.clippedPoints <= 0 || item.displayMaxAbs > 100.01) { failures.push(`${item.id} extreme percent values are not bounded with traceable markers`); } } if (contract.name === 'daily' && ['trendChart', 'dodChart', 'wowChart', 'marginChart', 'avgQtyChart'].includes(item.id)) { const allowedDateTicks = item.cssWidth < 500 ? 6 : 12; if (item.categoryTickLabels.length > allowedDateTicks) { failures.push(`${item.id} renders ${item.categoryTickLabels.length} overlapping date ticks`); } } if (contract.name === 'growth' && ['aovChart', 'marginChart'].includes(item.id) && item.dataPoints === 1 && item.maxPointRadius < 5) { failures.push(`${item.id} single point is not visibly emphasized`); } if (contract.name === 'sales' && item.id === 'barChart' && item.categoryTickLabels.length && item.categoryTickLabels.every(label => /^\d+$/.test(label))) { failures.push('barChart rendered numeric indexes instead of product labels'); } if (contract.name === 'daily' && item.id === 'trendChart' && item.chartLabels.some(label => !/^04\//.test(label))) { failures.push('trendChart contains labels outside the selected April period'); } if (contract.name === 'growth' && item.id === 'revenueChart' && item.chartLabels.some(label => label !== '2026-04')) { failures.push('revenueChart contains labels outside the selected April period'); } if (contract.name === 'sales' && item.id === 'monthlyChart' && item.chartLabels.some(label => label !== '2026-04')) { failures.push('monthlyChart contains labels outside the selected April period'); } if (contract.name === 'monthly' && item.id === 'compareChart' && item.chartLabels.length && item.chartLabels.some(label => label !== '2026/04')) { failures.push('compareChart contains labels outside the selected April period'); } } consoleErrors.forEach(error => failures.push(`console ${error}`)); return { route: contract.name, viewport, status, passed: failures.length === 0, failures, metrics }; } async function inspect(page, options, contract, viewport) { 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 { let response = await page.goto(fullUrl(options.baseUrl, contract.path), { waitUntil: 'domcontentloaded', timeout: options.timeoutMs, }); if (await loginIfNeeded(page, options.loginPassword, options.timeoutMs)) { consoleErrors.length = 0; response = await page.goto(fullUrl(options.baseUrl, contract.path), { waitUntil: 'domcontentloaded', timeout: options.timeoutMs, }); } await page.waitForFunction( ({ readyDataset, secondaryReadyDataset }) => { const primary = document.documentElement.dataset[readyDataset]; const secondary = secondaryReadyDataset ? document.documentElement.dataset[secondaryReadyDataset] : 'ready'; return ['ready', 'error'].includes(primary) && ['ready', 'error'].includes(secondary); }, { readyDataset: contract.readyDataset, secondaryReadyDataset: contract.secondaryReadyDataset }, { timeout: Math.min(options.timeoutMs, 30000) }, ).catch(() => null); if (options.settleMs > 0) await page.waitForTimeout(options.settleMs); const metrics = await collectMetrics(page, contract); return evaluateResult(contract, viewport, 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 contracts = options.routeNames.length ? ROUTE_CONTRACTS.filter(contract => options.routeNames.includes(contract.name)) : ROUTE_CONTRACTS; if (!contracts.length) throw new Error('No matching analysis routes.'); 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 context = await browser.newContext(); const page = await context.newPage(); const results = []; try { await primeAuthentication(page, options, contracts[0]); for (const viewport of options.viewports) { await page.setViewportSize(viewport); for (const contract of contracts) { const result = await inspect(page, options, contract, viewport); results.push(result); if (options.screenshotDir && (!result.passed || options.screenshotAll)) { const screenshotPath = path.join( options.screenshotDir, `${contract.name}_${viewport.width}x${viewport.height}.png`, ); if (options.screenshotAll && contract.screenshotTarget) { const target = page.locator(contract.screenshotTarget).first(); await target.scrollIntoViewIfNeeded().catch(() => {}); await page.waitForTimeout(200); const dataUrl = await target.evaluate(element => { const canvas = element.tagName === 'CANVAS' ? element : element.querySelector('canvas'); return canvas && typeof canvas.toDataURL === 'function' ? canvas.toDataURL('image/png') : ''; }).catch(() => ''); if (dataUrl.startsWith('data:image/png;base64,')) { fs.writeFileSync(screenshotPath, Buffer.from(dataUrl.split(',', 2)[1], 'base64')); } else { await target.screenshot({ path: screenshotPath }).catch(() => page.screenshot({ path: screenshotPath, fullPage: false })); } } else { await page.screenshot({ path: screenshotPath, fullPage: false }); } } } } } finally { await context.close().catch(() => {}); await browser.close(); } if (options.json) console.log(JSON.stringify(results, null, 2)); else results.forEach(result => { console.log(`${result.passed ? 'PASS' : 'FAIL'} ${result.route} ${result.viewport.width}x${result.viewport.height} targets=${result.metrics.targets.length}`); result.failures.forEach(failure => console.log(` ${failure}`)); }); if (results.some(result => !result.passed)) process.exitCode = 1; } main().catch(error => { console.error(error.message || error); process.exit(1); });