fix(analytics): enforce trustworthy chart rendering
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
This commit is contained in:
517
scripts/check_analysis_chart_integrity.js
Normal file
517
scripts/check_analysis_chart_integrity.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user