diff --git a/run/bun.lock b/run/bun.lock index 219db71..f088ba5 100644 --- a/run/bun.lock +++ b/run/bun.lock @@ -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=="], diff --git a/run/cli/commands/perf/index.ts b/run/cli/commands/perf/index.ts new file mode 100644 index 0000000..6d80cc8 --- /dev/null +++ b/run/cli/commands/perf/index.ts @@ -0,0 +1,7 @@ +/** + * Performance Metrics Commands + * + * Commands for measuring browser performance metrics against production builds. + */ + +export { perf } from './perf'; diff --git a/run/cli/commands/perf/perf.ts b/run/cli/commands/perf/perf.ts new file mode 100644 index 0000000..404d3bc --- /dev/null +++ b/run/cli/commands/perf/perf.ts @@ -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 = { + 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 { + 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; + } catch { + return null; + } + }) + .filter(Boolean) as Array>; + + // 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 { + // 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 { + 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` + ); + } + } +} diff --git a/run/cli/index.ts b/run/cli/index.ts index e3f2c41..a9955ba 100644 --- a/run/cli/index.ts +++ b/run/cli/index.ts @@ -91,6 +91,9 @@ const lazyCommands: Record = { // 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=, --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) diff --git a/run/core/perf-collector.ts b/run/core/perf-collector.ts new file mode 100644 index 0000000..2f5d9df --- /dev/null +++ b/run/core/perf-collector.ts @@ -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; + + 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 { + 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 { + 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 { + 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 { + return page.evaluate(() => { + return performance.getEntriesByType('paint').map((entry) => ({ + name: entry.name, + startTime: entry.startTime, + entryType: entry.entryType, + })); + }); + } + + private async getLCPEntry(page: Page): Promise { + return page.evaluate(() => { + return new Promise((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 { + 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 { + 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 }; +} diff --git a/run/core/perf-reporter.ts b/run/core/perf-reporter.ts new file mode 100644 index 0000000..2c1a7e4 --- /dev/null +++ b/run/core/perf-reporter.ts @@ -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'); +}