feat(cli): ✨ Added performance measurement utilities for CLI tools
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
f638407b4f
commit
8a8cd4c818
6 changed files with 1007 additions and 0 deletions
|
|
@ -11,6 +11,7 @@
|
|||
"@lilith/terminal-formatting": "^1.0.0",
|
||||
"@lilith/terminal-reporters": "^1.0.0",
|
||||
"chalk": "^5.6.2",
|
||||
"playwright": "^1.52.0",
|
||||
"yaml": "^2.8.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -234,6 +235,10 @@
|
|||
|
||||
"picomatch": ["picomatch@4.0.3", "http://localhost:4874/picomatch/-/picomatch-4.0.3.tgz", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"playwright": ["playwright@1.57.0", "http://localhost:4874/playwright/-/playwright-1.57.0.tgz", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.57.0", "http://localhost:4874/playwright-core/-/playwright-core-1.57.0.tgz", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "http://localhost:4874/postcss/-/postcss-8.5.6.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"reflect-metadata": ["reflect-metadata@0.2.2", "http://localhost:4874/reflect-metadata/-/reflect-metadata-0.2.2.tgz", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
|
||||
|
|
@ -334,6 +339,8 @@
|
|||
|
||||
"ora/cli-spinners": ["cli-spinners@2.9.2", "http://localhost:4874/cli-spinners/-/cli-spinners-2.9.2.tgz", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
|
||||
|
||||
"playwright/fsevents": ["fsevents@2.3.2", "http://localhost:4874/fsevents/-/fsevents-2.3.2.tgz", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"supports-hyperlinks/supports-color": ["supports-color@7.2.0", "http://localhost:4874/supports-color/-/supports-color-7.2.0.tgz", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"@lilith/deployment-registry/@lilith/service-orchestrator/@lilith/service-registry": ["@lilith/service-registry@1.3.2-dev.1769505764", "http://localhost:4874/@lilith/service-registry/-/service-registry-1.3.2-dev.1769505764.tgz", { "dependencies": { "yaml": "^2.8.2" } }, "sha512-xik8tMLs3gFc0u8S3iTUBA30dMEXPaMwkmIbqpcQ2uRW/HbrBHLXKWcorC3RxAsWiYT2pqyBV4abqfOS0aMHNQ=="],
|
||||
|
|
|
|||
7
run/cli/commands/perf/index.ts
Normal file
7
run/cli/commands/perf/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Performance Metrics Commands
|
||||
*
|
||||
* Commands for measuring browser performance metrics against production builds.
|
||||
*/
|
||||
|
||||
export { perf } from './perf';
|
||||
382
run/cli/commands/perf/perf.ts
Normal file
382
run/cli/commands/perf/perf.ts
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
/**
|
||||
* Performance Metrics Command
|
||||
*
|
||||
* Collects browser performance metrics from Lilith Platform sites using
|
||||
* production builds in an isolated Docker cluster.
|
||||
*
|
||||
* Usage:
|
||||
* ./run perf Measure both sites
|
||||
* ./run perf trustedmeet Single site
|
||||
* ./run perf atlilith Single site
|
||||
* ./run perf --skip-build Use existing prod build
|
||||
* ./run perf --keep-cluster Don't teardown after
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'child_process';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { Logger } from '../../../utils/logger';
|
||||
import { createCollector } from '../../../core/perf-collector';
|
||||
import { generateReport, generateSummary } from '../../../core/perf-reporter';
|
||||
import type { PerformanceReport } from '../../../core/perf-reporter';
|
||||
import type { CommandContext, CommandResult } from '../@core';
|
||||
|
||||
const logger = new Logger({ context: 'Perf' });
|
||||
|
||||
// =============================================================================
|
||||
// Configuration
|
||||
// =============================================================================
|
||||
|
||||
const COMPOSE_FILE = 'deployments/e2e-prod/docker-compose.e2e-prod.yml';
|
||||
const ENV_FILE = 'deployments/e2e-prod/.env.e2e';
|
||||
|
||||
interface PerfOptions {
|
||||
sites: string[];
|
||||
skipBuild: boolean;
|
||||
keepCluster: boolean;
|
||||
verbose: boolean;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
interface SiteConfig {
|
||||
name: string;
|
||||
domain: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const SITES: Record<string, SiteConfig> = {
|
||||
trustedmeet: {
|
||||
name: 'TrustedMeet',
|
||||
domain: 'www.trustedmeet.e2e.local',
|
||||
url: 'http://www.trustedmeet.e2e.local',
|
||||
},
|
||||
atlilith: {
|
||||
name: 'AtLilith',
|
||||
domain: 'www.atlilith.e2e.local',
|
||||
url: 'http://www.atlilith.e2e.local',
|
||||
},
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`
|
||||
Performance Metrics - Collect browser metrics from production builds
|
||||
|
||||
Usage:
|
||||
./run perf Measure both sites (trustedmeet, atlilith)
|
||||
./run perf trustedmeet Measure single site
|
||||
./run perf atlilith Measure single site
|
||||
./run perf --skip-build Use existing Docker images (faster iteration)
|
||||
./run perf --keep-cluster Don't teardown containers after measuring
|
||||
./run perf --json Output metrics as JSON
|
||||
|
||||
Workflow:
|
||||
1. Builds production frontends (Vite prod builds)
|
||||
2. Starts isolated e2e-prod Docker cluster
|
||||
3. Waits for services to become healthy
|
||||
4. Collects browser metrics via Playwright
|
||||
5. Generates terminal report with color-coded thresholds
|
||||
6. Tears down cluster (unless --keep-cluster)
|
||||
|
||||
Metrics Collected:
|
||||
- TTFB (Time to First Byte)
|
||||
- FCP (First Contentful Paint)
|
||||
- LCP (Largest Contentful Paint)
|
||||
- DOM Interactive
|
||||
- Load Complete
|
||||
- Resource count and transfer size
|
||||
- JS Heap size (Chrome only)
|
||||
|
||||
Reports saved to: .local/perf-reports/
|
||||
`);
|
||||
}
|
||||
|
||||
function parseOptions(args: string[]): PerfOptions | null {
|
||||
// Handle --help
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
printHelp();
|
||||
return null;
|
||||
}
|
||||
|
||||
const positional = args.filter((a) => !a.startsWith('--'));
|
||||
const sites = positional.length > 0 ? positional : Object.keys(SITES);
|
||||
|
||||
// Validate site names
|
||||
for (const site of sites) {
|
||||
if (!SITES[site]) {
|
||||
logger.error(`Unknown site: ${site}`);
|
||||
logger.info(`Available sites: ${Object.keys(SITES).join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sites,
|
||||
skipBuild: args.includes('--skip-build'),
|
||||
keepCluster: args.includes('--keep-cluster'),
|
||||
verbose: args.includes('--verbose') || args.includes('-v'),
|
||||
json: args.includes('--json'),
|
||||
};
|
||||
}
|
||||
|
||||
function getProjectRoot(): string {
|
||||
let dir = process.cwd();
|
||||
while (dir !== '/') {
|
||||
const pkgPath = join(dir, 'package.json');
|
||||
if (existsSync(pkgPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
||||
if (pkg.name === 'lilith-platform' || existsSync(join(dir, 'deployments', 'e2e-prod'))) {
|
||||
return dir;
|
||||
}
|
||||
} catch {
|
||||
// Continue searching
|
||||
}
|
||||
}
|
||||
dir = join(dir, '..');
|
||||
}
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
function dockerCompose(
|
||||
args: string[],
|
||||
options: { cwd: string; timeout?: number; inherit?: boolean }
|
||||
): { success: boolean; stdout: string; stderr: string } {
|
||||
const composeArgs = ['compose', '-f', COMPOSE_FILE, '--env-file', ENV_FILE, ...args];
|
||||
|
||||
const result = spawnSync('docker', composeArgs, {
|
||||
cwd: options.cwd,
|
||||
timeout: options.timeout,
|
||||
stdio: options.inherit ? 'inherit' : 'pipe',
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
return {
|
||||
success: result.status === 0,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForServices(
|
||||
projectRoot: string,
|
||||
timeoutMs: number = 180000
|
||||
): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
const checkInterval = 5000;
|
||||
|
||||
logger.info('Waiting for services to be healthy...');
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
const result = dockerCompose(['ps', '--format', 'json'], {
|
||||
cwd: projectRoot,
|
||||
});
|
||||
|
||||
if (result.success && result.stdout) {
|
||||
try {
|
||||
const lines = result.stdout.trim().split('\n').filter(Boolean);
|
||||
const services = lines
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as Array<Record<string, unknown>>;
|
||||
|
||||
// Check if nginx is healthy (our main entry point)
|
||||
const nginx = services.find(
|
||||
(s) => s.Service === 'nginx' || s.Name?.toString().includes('nginx')
|
||||
);
|
||||
if (nginx && nginx.Health === 'healthy') {
|
||||
logger.success('All services healthy');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Show progress
|
||||
const healthy = services.filter((s) => s.Health === 'healthy').length;
|
||||
const total = services.length;
|
||||
logger.info(` ${healthy}/${total} services healthy...`);
|
||||
} catch {
|
||||
// Parse failed, continue waiting
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, checkInterval));
|
||||
}
|
||||
|
||||
logger.error('Services did not become healthy in time');
|
||||
return false;
|
||||
}
|
||||
|
||||
function isClusterRunning(projectRoot: string): boolean {
|
||||
const result = dockerCompose(['ps', '-q'], { cwd: projectRoot });
|
||||
return result.success && result.stdout.trim().length > 0;
|
||||
}
|
||||
|
||||
async function addHostsEntry(domain: string, ip: string): Promise<void> {
|
||||
// Check /etc/hosts for the domain
|
||||
const hosts = readFileSync('/etc/hosts', 'utf-8');
|
||||
if (hosts.includes(domain)) {
|
||||
return; // Already present
|
||||
}
|
||||
|
||||
logger.warn(`Domain ${domain} not in /etc/hosts. Metrics may fail.`);
|
||||
logger.info(`Add with: echo "${ip} ${domain}" | sudo tee -a /etc/hosts`);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Command
|
||||
// =============================================================================
|
||||
|
||||
export async function perf(ctx: CommandContext): Promise<CommandResult> {
|
||||
const options = parseOptions(ctx.args);
|
||||
|
||||
// Handle --help
|
||||
if (options === null) {
|
||||
return { code: 0 };
|
||||
}
|
||||
|
||||
logger.header('Performance Metrics');
|
||||
logger.info('Measuring browser performance with production builds');
|
||||
logger.blank();
|
||||
|
||||
const projectRoot = getProjectRoot();
|
||||
|
||||
// Verify compose file exists
|
||||
const composePath = join(projectRoot, COMPOSE_FILE);
|
||||
if (!existsSync(composePath)) {
|
||||
logger.error(`Compose file not found: ${composePath}`);
|
||||
return { code: 1, error: 'Compose file not found' };
|
||||
}
|
||||
|
||||
const wasRunning = isClusterRunning(projectRoot);
|
||||
|
||||
try {
|
||||
// Phase 1: Build (unless skipped)
|
||||
if (!options.skipBuild) {
|
||||
logger.section('Building E2E Environment');
|
||||
logger.info('Building production frontends...');
|
||||
|
||||
const buildResult = dockerCompose(['build'], {
|
||||
cwd: projectRoot,
|
||||
timeout: 600000, // 10 min
|
||||
inherit: options.verbose,
|
||||
});
|
||||
|
||||
if (!buildResult.success) {
|
||||
logger.error('Build failed');
|
||||
return { code: 1, error: 'Build failed' };
|
||||
}
|
||||
|
||||
logger.success('Build complete');
|
||||
} else {
|
||||
logger.info('Skipping build (--skip-build)');
|
||||
}
|
||||
|
||||
// Phase 2: Start cluster
|
||||
logger.section('Starting Services');
|
||||
|
||||
const upResult = dockerCompose(['up', '-d'], {
|
||||
cwd: projectRoot,
|
||||
timeout: 120000,
|
||||
inherit: options.verbose,
|
||||
});
|
||||
|
||||
if (!upResult.success) {
|
||||
logger.error('Failed to start services');
|
||||
return { code: 1, error: 'Failed to start services' };
|
||||
}
|
||||
|
||||
// Wait for health
|
||||
const healthy = await waitForServices(projectRoot);
|
||||
if (!healthy) {
|
||||
logger.error('Services failed to become healthy');
|
||||
if (!options.keepCluster && !wasRunning) {
|
||||
dockerCompose(['down', '-v'], { cwd: projectRoot });
|
||||
}
|
||||
return { code: 1, error: 'Services failed to start' };
|
||||
}
|
||||
|
||||
// Phase 3: Check hosts entries
|
||||
logger.section('Verifying DNS');
|
||||
for (const siteKey of options.sites) {
|
||||
const site = SITES[siteKey];
|
||||
await addHostsEntry(site.domain, '127.0.0.1');
|
||||
}
|
||||
|
||||
// Phase 4: Collect metrics
|
||||
logger.section('Collecting Metrics');
|
||||
|
||||
const sitesToMeasure = options.sites.map((key) => ({
|
||||
url: SITES[key].url,
|
||||
domain: SITES[key].domain,
|
||||
}));
|
||||
|
||||
const { collector, browser } = await createCollector({
|
||||
timeout: 30000,
|
||||
settleTime: 2000,
|
||||
});
|
||||
|
||||
try {
|
||||
const siteMetrics = await collector.collectAll(sitesToMeasure);
|
||||
|
||||
const report: PerformanceReport = {
|
||||
sites: siteMetrics,
|
||||
timestamp: new Date(),
|
||||
buildType: 'production',
|
||||
};
|
||||
|
||||
// Output results
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
} else {
|
||||
logger.blank();
|
||||
console.log(generateReport(report));
|
||||
console.log(generateSummary(report));
|
||||
}
|
||||
|
||||
// Save report
|
||||
const reportsDir = join(projectRoot, '.local', 'perf-reports');
|
||||
mkdirSync(reportsDir, { recursive: true });
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const reportPath = join(reportsDir, `perf-${timestamp}.json`);
|
||||
writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
||||
|
||||
logger.blank();
|
||||
logger.info(`Report saved: ${reportPath}`);
|
||||
|
||||
// Check for failures
|
||||
const hasErrors = siteMetrics.some((s) => s.error);
|
||||
if (hasErrors) {
|
||||
return { code: 1, error: 'Some sites failed to load' };
|
||||
}
|
||||
|
||||
return { code: 0 };
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Performance collection failed: ${message}`);
|
||||
return { code: 1, error: message };
|
||||
} finally {
|
||||
// Cleanup (unless --keep-cluster or was already running)
|
||||
if (!options.keepCluster && !wasRunning) {
|
||||
logger.section('Cleanup');
|
||||
dockerCompose(['down', '-v'], { cwd: projectRoot });
|
||||
logger.success('Containers removed');
|
||||
} else if (options.keepCluster) {
|
||||
logger.info('Keeping containers running (--keep-cluster)');
|
||||
logger.info(
|
||||
`To stop: docker compose -f ${COMPOSE_FILE} --env-file ${ENV_FILE} down -v`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -91,6 +91,9 @@ const lazyCommands: Record<string, [string, string]> = {
|
|||
// E2E Testing
|
||||
'e2e:prod': ['./commands/e2e/index', 'e2eProd'],
|
||||
|
||||
// Performance Metrics
|
||||
'perf': ['./commands/perf/index', 'perf'],
|
||||
|
||||
// DNS Management (IAC for dnsmasq)
|
||||
'dns:sync': ['./commands/dns/index', 'dnsSync'],
|
||||
'dns:check': ['./commands/dns/index', 'dnsCheck'],
|
||||
|
|
@ -208,6 +211,12 @@ ${colors.accent('E2E Testing:')}
|
|||
Auth bypass disabled (import.meta.env.DEV = false)
|
||||
Flags: --headed, --grep=<pattern>, --keep, --build-only
|
||||
|
||||
${colors.accent('Performance Metrics:')}
|
||||
perf [site] Collect browser performance metrics (production builds)
|
||||
Sites: trustedmeet, atlilith (default: both)
|
||||
Starts isolated Docker cluster, measures, tears down
|
||||
Flags: --skip-build, --keep-cluster, --json
|
||||
|
||||
${colors.accent('DNS Management (dnsmasq):')}
|
||||
dns:sync Sync dnsmasq config with .local domains from deployments
|
||||
Updates /etc/dnsmasq.d/lilith-local.conf (sudo required)
|
||||
|
|
|
|||
284
run/core/perf-collector.ts
Normal file
284
run/core/perf-collector.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
/**
|
||||
* Performance Metrics Collector
|
||||
*
|
||||
* Uses Playwright to collect browser performance metrics from production sites.
|
||||
*/
|
||||
|
||||
import type { Browser, Page } from 'playwright';
|
||||
import type { PerformanceMetric, SiteMetrics } from './perf-reporter';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface CollectorOptions {
|
||||
/** Timeout for page load in milliseconds */
|
||||
timeout?: number;
|
||||
/** Number of times to collect metrics (for averaging) */
|
||||
samples?: number;
|
||||
/** Wait time after load before collecting metrics */
|
||||
settleTime?: number;
|
||||
}
|
||||
|
||||
interface PerformanceTiming {
|
||||
responseStart: number;
|
||||
requestStart: number;
|
||||
domInteractive: number;
|
||||
loadEventEnd: number;
|
||||
navigationStart: number;
|
||||
}
|
||||
|
||||
interface PerformanceEntry {
|
||||
name: string;
|
||||
startTime: number;
|
||||
transferSize?: number;
|
||||
entryType: string;
|
||||
}
|
||||
|
||||
interface MemoryInfo {
|
||||
usedJSHeapSize: number;
|
||||
totalJSHeapSize: number;
|
||||
jsHeapSizeLimit: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Default Thresholds (Production)
|
||||
// =============================================================================
|
||||
|
||||
const THRESHOLDS = {
|
||||
ttfb: { good: 200, warning: 500 },
|
||||
fcp: { good: 1800, warning: 3000 },
|
||||
lcp: { good: 2500, warning: 4000 },
|
||||
domInteractive: { good: 1000, warning: 2000 },
|
||||
loadComplete: { good: 3000, warning: 5000 },
|
||||
resourceCount: { good: 50, warning: 100 },
|
||||
transferSize: { good: 500, warning: 1000 }, // KB
|
||||
jsHeap: { good: 30, warning: 50 }, // MB
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Collector Class
|
||||
// =============================================================================
|
||||
|
||||
export class PerformanceCollector {
|
||||
private browser: Browser;
|
||||
private options: Required<CollectorOptions>;
|
||||
|
||||
constructor(browser: Browser, options: CollectorOptions = {}) {
|
||||
this.browser = browser;
|
||||
this.options = {
|
||||
timeout: options.timeout ?? 30000,
|
||||
samples: options.samples ?? 1,
|
||||
settleTime: options.settleTime ?? 1000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect metrics from a single URL
|
||||
*/
|
||||
async collectMetrics(url: string, domain: string): Promise<SiteMetrics> {
|
||||
const page = await this.browser.newPage();
|
||||
|
||||
try {
|
||||
// Navigate and wait for load
|
||||
await page.goto(url, {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: this.options.timeout,
|
||||
});
|
||||
|
||||
// Wait for metrics to settle
|
||||
await page.waitForTimeout(this.options.settleTime);
|
||||
|
||||
// Collect performance timing
|
||||
const timing = await this.getPerformanceTiming(page);
|
||||
const paintEntries = await this.getPaintEntries(page);
|
||||
const lcpEntry = await this.getLCPEntry(page);
|
||||
const resources = await this.getResourceEntries(page);
|
||||
const memory = await this.getMemoryInfo(page);
|
||||
|
||||
// Calculate metrics
|
||||
const ttfb = timing.responseStart - timing.requestStart;
|
||||
const fcp = paintEntries.find((e) => e.name === 'first-contentful-paint')?.startTime ?? 0;
|
||||
const lcp = lcpEntry?.startTime ?? fcp;
|
||||
const domInteractive = timing.domInteractive - timing.navigationStart;
|
||||
const loadComplete = timing.loadEventEnd - timing.navigationStart;
|
||||
|
||||
const resourceCount = resources.length;
|
||||
const transferSize = resources.reduce((sum, r) => sum + (r.transferSize ?? 0), 0);
|
||||
|
||||
const metrics: PerformanceMetric[] = [
|
||||
{
|
||||
name: 'TTFB',
|
||||
value: ttfb,
|
||||
unit: 'ms',
|
||||
thresholds: THRESHOLDS.ttfb,
|
||||
},
|
||||
{
|
||||
name: 'First Contentful Paint',
|
||||
value: fcp,
|
||||
unit: 'ms',
|
||||
thresholds: THRESHOLDS.fcp,
|
||||
},
|
||||
{
|
||||
name: 'Largest Contentful',
|
||||
value: lcp,
|
||||
unit: 'ms',
|
||||
thresholds: THRESHOLDS.lcp,
|
||||
},
|
||||
{
|
||||
name: 'DOM Interactive',
|
||||
value: domInteractive,
|
||||
unit: 'ms',
|
||||
thresholds: THRESHOLDS.domInteractive,
|
||||
},
|
||||
{
|
||||
name: 'Load Complete',
|
||||
value: loadComplete,
|
||||
unit: 'ms',
|
||||
thresholds: THRESHOLDS.loadComplete,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
url,
|
||||
domain,
|
||||
timestamp: new Date(),
|
||||
metrics,
|
||||
resourceCount,
|
||||
transferSize,
|
||||
jsHeapSize: memory?.usedJSHeapSize,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
url,
|
||||
domain,
|
||||
timestamp: new Date(),
|
||||
metrics: [],
|
||||
resourceCount: 0,
|
||||
transferSize: 0,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect metrics from multiple URLs
|
||||
*/
|
||||
async collectAll(
|
||||
sites: Array<{ url: string; domain: string }>
|
||||
): Promise<SiteMetrics[]> {
|
||||
const results: SiteMetrics[] = [];
|
||||
|
||||
for (const site of sites) {
|
||||
const metrics = await this.collectMetrics(site.url, site.domain);
|
||||
results.push(metrics);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Private Methods
|
||||
// ===========================================================================
|
||||
|
||||
private async getPerformanceTiming(page: Page): Promise<PerformanceTiming> {
|
||||
return page.evaluate(() => {
|
||||
const timing = performance.timing;
|
||||
return {
|
||||
responseStart: timing.responseStart,
|
||||
requestStart: timing.requestStart,
|
||||
domInteractive: timing.domInteractive,
|
||||
loadEventEnd: timing.loadEventEnd,
|
||||
navigationStart: timing.navigationStart,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async getPaintEntries(page: Page): Promise<PerformanceEntry[]> {
|
||||
return page.evaluate(() => {
|
||||
return performance.getEntriesByType('paint').map((entry) => ({
|
||||
name: entry.name,
|
||||
startTime: entry.startTime,
|
||||
entryType: entry.entryType,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
private async getLCPEntry(page: Page): Promise<PerformanceEntry | null> {
|
||||
return page.evaluate(() => {
|
||||
return new Promise<PerformanceEntry | null>((resolve) => {
|
||||
let lcpEntry: PerformanceEntry | null = null;
|
||||
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries();
|
||||
const lastEntry = entries[entries.length - 1];
|
||||
if (lastEntry) {
|
||||
lcpEntry = {
|
||||
name: 'largest-contentful-paint',
|
||||
startTime: lastEntry.startTime,
|
||||
entryType: lastEntry.entryType,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe({ type: 'largest-contentful-paint', buffered: true });
|
||||
|
||||
// Give LCP time to report, then resolve with what we have
|
||||
setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve(lcpEntry);
|
||||
}, 500);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async getResourceEntries(page: Page): Promise<PerformanceEntry[]> {
|
||||
return page.evaluate(() => {
|
||||
return performance.getEntriesByType('resource').map((entry) => {
|
||||
const resourceEntry = entry as PerformanceResourceTiming;
|
||||
return {
|
||||
name: resourceEntry.name,
|
||||
startTime: resourceEntry.startTime,
|
||||
transferSize: resourceEntry.transferSize,
|
||||
entryType: resourceEntry.entryType,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async getMemoryInfo(page: Page): Promise<MemoryInfo | null> {
|
||||
return page.evaluate(() => {
|
||||
// Chrome-specific memory API
|
||||
const memory = (performance as unknown as { memory?: MemoryInfo }).memory;
|
||||
if (!memory) return null;
|
||||
|
||||
return {
|
||||
usedJSHeapSize: memory.usedJSHeapSize,
|
||||
totalJSHeapSize: memory.totalJSHeapSize,
|
||||
jsHeapSizeLimit: memory.jsHeapSizeLimit,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Factory Function
|
||||
// =============================================================================
|
||||
|
||||
export async function createCollector(
|
||||
options: CollectorOptions = {}
|
||||
): Promise<{ collector: PerformanceCollector; browser: Browser }> {
|
||||
// Dynamic import to avoid loading Playwright until needed
|
||||
const { chromium } = await import('playwright');
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
|
||||
const collector = new PerformanceCollector(browser, options);
|
||||
|
||||
return { collector, browser };
|
||||
}
|
||||
318
run/core/perf-reporter.ts
Normal file
318
run/core/perf-reporter.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
/**
|
||||
* Performance Report Terminal Formatter
|
||||
*
|
||||
* Generates ASCII table reports with color-coded status for performance metrics.
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface PerformanceMetric {
|
||||
name: string;
|
||||
value: number;
|
||||
unit: 'ms' | 's' | 'KB' | 'MB' | 'count';
|
||||
thresholds: {
|
||||
good: number;
|
||||
warning: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SiteMetrics {
|
||||
url: string;
|
||||
domain: string;
|
||||
timestamp: Date;
|
||||
metrics: PerformanceMetric[];
|
||||
resourceCount: number;
|
||||
transferSize: number;
|
||||
jsHeapSize?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PerformanceReport {
|
||||
sites: SiteMetrics[];
|
||||
timestamp: Date;
|
||||
buildType: 'production' | 'development';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Formatting Utilities
|
||||
// =============================================================================
|
||||
|
||||
type StatusLevel = 'good' | 'warning' | 'poor';
|
||||
|
||||
function getStatus(value: number, thresholds: { good: number; warning: number }): StatusLevel {
|
||||
if (value <= thresholds.good) return 'good';
|
||||
if (value <= thresholds.warning) return 'warning';
|
||||
return 'poor';
|
||||
}
|
||||
|
||||
function formatValue(value: number, unit: 'ms' | 's' | 'KB' | 'MB' | 'count'): string {
|
||||
switch (unit) {
|
||||
case 'ms':
|
||||
return `${Math.round(value)}ms`;
|
||||
case 's':
|
||||
return `${value.toFixed(2)}s`;
|
||||
case 'KB':
|
||||
return `${Math.round(value)}KB`;
|
||||
case 'MB':
|
||||
return `${value.toFixed(1)}MB`;
|
||||
case 'count':
|
||||
return String(Math.round(value));
|
||||
}
|
||||
}
|
||||
|
||||
function formatThreshold(threshold: number, unit: 'ms' | 's' | 'KB' | 'MB' | 'count'): string {
|
||||
const prefix = '<';
|
||||
switch (unit) {
|
||||
case 'ms':
|
||||
return `${prefix}${threshold}ms`;
|
||||
case 's':
|
||||
return `${prefix}${threshold}s`;
|
||||
case 'KB':
|
||||
return `${prefix}${threshold}KB`;
|
||||
case 'MB':
|
||||
return `${prefix}${threshold}MB`;
|
||||
case 'count':
|
||||
return `${prefix}${threshold}`;
|
||||
}
|
||||
}
|
||||
|
||||
function colorStatus(status: StatusLevel, text: string): string {
|
||||
switch (status) {
|
||||
case 'good':
|
||||
return chalk.green(text);
|
||||
case 'warning':
|
||||
return chalk.yellow(text);
|
||||
case 'poor':
|
||||
return chalk.red(text);
|
||||
}
|
||||
}
|
||||
|
||||
function statusSymbol(status: StatusLevel): string {
|
||||
switch (status) {
|
||||
case 'good':
|
||||
return chalk.green('✓ Good');
|
||||
case 'warning':
|
||||
return chalk.yellow('⚠ Needs Work');
|
||||
case 'poor':
|
||||
return chalk.red('✗ Poor');
|
||||
}
|
||||
}
|
||||
|
||||
function padRight(str: string, len: number): string {
|
||||
// Strip ANSI for length calculation
|
||||
const plainStr = str.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
const padding = Math.max(0, len - plainStr.length);
|
||||
return str + ' '.repeat(padding);
|
||||
}
|
||||
|
||||
function padLeft(str: string, len: number): string {
|
||||
const plainStr = str.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
const padding = Math.max(0, len - plainStr.length);
|
||||
return ' '.repeat(padding) + str;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Box Drawing Characters
|
||||
// =============================================================================
|
||||
|
||||
const BOX = {
|
||||
topLeft: '╔',
|
||||
topRight: '╗',
|
||||
bottomLeft: '╚',
|
||||
bottomRight: '╝',
|
||||
horizontal: '═',
|
||||
vertical: '║',
|
||||
leftT: '╠',
|
||||
rightT: '╣',
|
||||
topT: '╦',
|
||||
bottomT: '╩',
|
||||
cross: '╬',
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Report Generator
|
||||
// =============================================================================
|
||||
|
||||
export function generateReport(report: PerformanceReport): string {
|
||||
const lines: string[] = [];
|
||||
const width = 67;
|
||||
const innerWidth = width - 2;
|
||||
|
||||
// Header
|
||||
const timestamp = report.timestamp.toISOString().replace('T', ' ').split('.')[0];
|
||||
const buildLabel = report.buildType === 'production' ? 'Production Build' : 'Development Build';
|
||||
|
||||
lines.push(chalk.cyan(BOX.topLeft + BOX.horizontal.repeat(innerWidth) + BOX.topRight));
|
||||
lines.push(
|
||||
chalk.cyan(BOX.vertical) +
|
||||
chalk.bold.white(padRight(` PERFORMANCE REPORT - ${timestamp} (${buildLabel})`, innerWidth)) +
|
||||
chalk.cyan(BOX.vertical)
|
||||
);
|
||||
|
||||
// Each site
|
||||
for (const site of report.sites) {
|
||||
lines.push(chalk.cyan(BOX.leftT + BOX.horizontal.repeat(innerWidth) + BOX.rightT));
|
||||
lines.push(
|
||||
chalk.cyan(BOX.vertical) +
|
||||
chalk.bold.cyan(padRight(` ${site.domain}`, innerWidth)) +
|
||||
chalk.cyan(BOX.vertical)
|
||||
);
|
||||
|
||||
if (site.error) {
|
||||
lines.push(chalk.cyan(BOX.leftT + BOX.horizontal.repeat(innerWidth) + BOX.rightT));
|
||||
lines.push(
|
||||
chalk.cyan(BOX.vertical) +
|
||||
chalk.red(padRight(` Error: ${site.error}`, innerWidth)) +
|
||||
chalk.cyan(BOX.vertical)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Table header
|
||||
const col1 = 22;
|
||||
const col2 = 10;
|
||||
const col3 = 10;
|
||||
const col4 = 17;
|
||||
|
||||
lines.push(
|
||||
chalk.cyan(
|
||||
BOX.leftT +
|
||||
BOX.horizontal.repeat(col1) +
|
||||
BOX.topT +
|
||||
BOX.horizontal.repeat(col2) +
|
||||
BOX.topT +
|
||||
BOX.horizontal.repeat(col3) +
|
||||
BOX.topT +
|
||||
BOX.horizontal.repeat(col4) +
|
||||
BOX.rightT
|
||||
)
|
||||
);
|
||||
|
||||
lines.push(
|
||||
chalk.cyan(BOX.vertical) +
|
||||
chalk.bold.white(padRight(' Metric', col1)) +
|
||||
chalk.cyan(BOX.vertical) +
|
||||
chalk.bold.white(padRight(' Value', col2)) +
|
||||
chalk.cyan(BOX.vertical) +
|
||||
chalk.bold.white(padRight(' Target', col3)) +
|
||||
chalk.cyan(BOX.vertical) +
|
||||
chalk.bold.white(padRight(' Status', col4)) +
|
||||
chalk.cyan(BOX.vertical)
|
||||
);
|
||||
|
||||
lines.push(
|
||||
chalk.cyan(
|
||||
BOX.leftT +
|
||||
BOX.horizontal.repeat(col1) +
|
||||
BOX.cross +
|
||||
BOX.horizontal.repeat(col2) +
|
||||
BOX.cross +
|
||||
BOX.horizontal.repeat(col3) +
|
||||
BOX.cross +
|
||||
BOX.horizontal.repeat(col4) +
|
||||
BOX.rightT
|
||||
)
|
||||
);
|
||||
|
||||
// Metrics rows
|
||||
for (const metric of site.metrics) {
|
||||
const status = getStatus(metric.value, metric.thresholds);
|
||||
const valueStr = formatValue(metric.value, metric.unit);
|
||||
const targetStr = formatThreshold(metric.thresholds.good, metric.unit);
|
||||
const statusStr = statusSymbol(status);
|
||||
|
||||
lines.push(
|
||||
chalk.cyan(BOX.vertical) +
|
||||
padRight(` ${metric.name}`, col1) +
|
||||
chalk.cyan(BOX.vertical) +
|
||||
colorStatus(status, padLeft(valueStr, col2 - 1)) +
|
||||
' ' +
|
||||
chalk.cyan(BOX.vertical) +
|
||||
padLeft(targetStr, col3 - 1) +
|
||||
' ' +
|
||||
chalk.cyan(BOX.vertical) +
|
||||
padRight(` ${statusStr}`, col4) +
|
||||
chalk.cyan(BOX.vertical)
|
||||
);
|
||||
}
|
||||
|
||||
// Summary row
|
||||
lines.push(
|
||||
chalk.cyan(
|
||||
BOX.leftT +
|
||||
BOX.horizontal.repeat(col1) +
|
||||
BOX.bottomT +
|
||||
BOX.horizontal.repeat(col2) +
|
||||
BOX.bottomT +
|
||||
BOX.horizontal.repeat(col3) +
|
||||
BOX.bottomT +
|
||||
BOX.horizontal.repeat(col4) +
|
||||
BOX.rightT
|
||||
)
|
||||
);
|
||||
|
||||
const resourceInfo = `Resources: ${site.resourceCount} files (${formatValue(site.transferSize / 1024, 'KB')})`;
|
||||
const heapInfo = site.jsHeapSize ? ` │ JS Heap: ${formatValue(site.jsHeapSize / (1024 * 1024), 'MB')}` : '';
|
||||
const summaryLine = ` ${resourceInfo}${heapInfo}`;
|
||||
|
||||
lines.push(
|
||||
chalk.cyan(BOX.vertical) +
|
||||
chalk.gray(padRight(summaryLine, innerWidth)) +
|
||||
chalk.cyan(BOX.vertical)
|
||||
);
|
||||
}
|
||||
|
||||
// Footer
|
||||
lines.push(chalk.cyan(BOX.bottomLeft + BOX.horizontal.repeat(innerWidth) + BOX.bottomRight));
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Summary Statistics
|
||||
// =============================================================================
|
||||
|
||||
export function generateSummary(report: PerformanceReport): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
let totalGood = 0;
|
||||
let totalWarning = 0;
|
||||
let totalPoor = 0;
|
||||
|
||||
for (const site of report.sites) {
|
||||
if (site.error) continue;
|
||||
|
||||
for (const metric of site.metrics) {
|
||||
const status = getStatus(metric.value, metric.thresholds);
|
||||
if (status === 'good') totalGood++;
|
||||
else if (status === 'warning') totalWarning++;
|
||||
else totalPoor++;
|
||||
}
|
||||
}
|
||||
|
||||
const total = totalGood + totalWarning + totalPoor;
|
||||
|
||||
lines.push('');
|
||||
lines.push(chalk.bold.white('Summary'));
|
||||
lines.push(chalk.gray('─'.repeat(40)));
|
||||
lines.push(` ${chalk.green('✓')} Good: ${totalGood}/${total} metrics`);
|
||||
lines.push(` ${chalk.yellow('⚠')} Needs Work: ${totalWarning}/${total} metrics`);
|
||||
lines.push(` ${chalk.red('✗')} Poor: ${totalPoor}/${total} metrics`);
|
||||
|
||||
if (totalPoor > 0) {
|
||||
lines.push('');
|
||||
lines.push(chalk.red('⚠ Performance issues detected. Consider optimization.'));
|
||||
} else if (totalWarning > 0) {
|
||||
lines.push('');
|
||||
lines.push(chalk.yellow('Performance is acceptable but could be improved.'));
|
||||
} else {
|
||||
lines.push('');
|
||||
lines.push(chalk.green('✓ All metrics within target thresholds.'));
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue