feat(cli): Added performance measurement utilities for CLI tools

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Quinn Ftw 2026-02-04 00:36:17 -08:00
parent f638407b4f
commit 8a8cd4c818
6 changed files with 1007 additions and 0 deletions

View file

@ -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=="],

View file

@ -0,0 +1,7 @@
/**
* Performance Metrics Commands
*
* Commands for measuring browser performance metrics against production builds.
*/
export { perf } from './perf';

View 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`
);
}
}
}

View file

@ -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
View 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
View 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');
}