/** * Electron Cold Startup Benchmark * * Launches the Electron app N times and measures per-phase startup timings by * parsing the electron-log file for [AionUi:ready] / [AionUi:init] / * [AionUi:process] marks, plus `ready-to-show` / `did-finish-load` / * time-to-interactive (chat input visible). * * Optional `--with-memory` mode samples RSS / heap in the main and renderer * processes at three checkpoints (idle, afterConversation, afterClose) to * estimate per-conversation memory pressure and leaks. * * Usage: * bunx tsx scripts/benchmark-startup.ts [--iterations 5] [--cooldown 2000] * bunx tsx scripts/benchmark-startup.ts --with-memory */ import { _electron as electron, type ElectronApplication, type Page } from 'playwright'; import path from 'path'; import fs from 'fs'; import os from 'os'; // ── CLI args ──────────────────────────────────────────────────────────────── type Args = { iterations: number; cooldownMs: number; launchTimeoutMs: number; interactiveTimeoutMs: number; outputJson: string | null; withMemory: boolean; }; function parseArgs(): Args { const argv = process.argv.slice(2); const args: Args = { iterations: 5, cooldownMs: 2_000, launchTimeoutMs: 90_000, interactiveTimeoutMs: 60_000, outputJson: null, withMemory: false, }; for (let i = 0; i < argv.length; i++) { const flag = argv[i]; const next = argv[i + 1]; if (flag === '--iterations' && next) { args.iterations = parseInt(next, 10); i++; } else if (flag === '--cooldown' && next) { args.cooldownMs = parseInt(next, 10); i++; } else if (flag === '--launch-timeout' && next) { args.launchTimeoutMs = parseInt(next, 10); i++; } else if (flag === '--interactive-timeout' && next) { args.interactiveTimeoutMs = parseInt(next, 10); i++; } else if (flag === '--output' && next) { args.outputJson = next; i++; } else if (flag === '--with-memory') { args.withMemory = true; } } if (!Number.isFinite(args.iterations) || args.iterations < 1) args.iterations = 5; return args; } // ── Types ─────────────────────────────────────────────────────────────────── type MainMemorySample = { rss: number; heapTotal: number; heapUsed: number; external: number; arrayBuffers: number; }; type RendererMemorySample = { usedSize: number; totalSize: number; }; type MemorySnapshot = { main: MainMemorySample | null; renderer: RendererMemorySample | null; takenAt: string; }; type MemoryProfile = { idle: MemorySnapshot | null; afterConversation: MemorySnapshot | null; afterClose: MemorySnapshot | null; // Leak estimate = afterClose - idle (main RSS + renderer usedSize) leakMainRssBytes: number; leakRendererUsedBytes: number; // Convenience deltas (afterConversation - idle) openDeltaMainRssBytes: number; openDeltaRendererUsedBytes: number; }; type StartupTiming = { iteration: number; timestamp: string; failed: boolean; failureReason: string | null; // Wall-clock measurements from Playwright side wallFirstWindowMs: number; wallDomContentLoadedMs: number; wallTimeToInteractiveMs: number; wallTotalMs: number; // Parsed from [AionUi:ready] marks readyInitializeProcessMs: number; readyInitializeZoomFactorMs: number; readyCreateWindowMs: number; readyInitializeAcpDetectorMs: number; // Parsed from [AionUi:init] marks initTotalMs: number; // Parsed from [AionUi:process] marks processInitStorageMs: number; processExtensionRegistryMs: number; processChannelManagerMs: number; // Parsed from window lifecycle logs logRendererDidFinishLoadPresent: boolean; logWindowReadyToShowPresent: boolean; logShowingMainWindowPresent: boolean; // Optional memory profile (only when --with-memory is passed) memory: MemoryProfile | null; }; // ── Selectors ─────────────────────────────────────────────────────────────── const GUID_INPUT = '.guid-input-card-shell textarea'; const AGENT_PILL = '[data-agent-pill="true"]'; // ── Log file helpers ──────────────────────────────────────────────────────── function getLogFilePath(): string { const today = new Date().toISOString().slice(0, 10); const candidates: string[] = []; if (process.platform === 'darwin') { candidates.push( path.join(os.homedir(), 'Library', 'Logs', 'AionUi-Dev', `${today}.log`), path.join(os.homedir(), 'Library', 'Logs', 'AionUi', `${today}.log`) ); } else if (process.platform === 'win32') { const appData = process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming'); candidates.push(path.join(appData, 'AionUi', 'logs', `${today}.log`)); } else { candidates.push(path.join(os.homedir(), '.config', 'AionUi', 'logs', `${today}.log`)); } return candidates.find((p) => fs.existsSync(p)) ?? candidates[0]; } function getLogFileSize(logPath: string): number { try { return fs.statSync(logPath).size; } catch { return 0; } } function readNewLogLines(logPath: string, offset: number): string[] { try { const currentSize = fs.statSync(logPath).size; if (currentSize <= offset) return []; const fd = fs.openSync(logPath, 'r'); const buf = Buffer.alloc(currentSize - offset); fs.readSync(fd, buf, 0, buf.length, offset); fs.closeSync(fd); return buf.toString('utf-8').split('\n'); } catch { return []; } } // ── Log parsing ───────────────────────────────────────────────────────────── // Matches: [AionUi:ready]