fix(analytics): enforce trustworthy chart rendering
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

This commit is contained in:
ogt
2026-07-16 15:55:22 +08:00
parent e23d1c0266
commit 911393190d
18 changed files with 1230 additions and 155 deletions

View File

@@ -0,0 +1,517 @@
#!/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);
});