518 lines
23 KiB
JavaScript
518 lines
23 KiB
JavaScript
#!/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);
|
|
});
|