diff --git a/README.md b/README.md index 4b8a2b3..5772a7f 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,8 @@ agent-browser dialog dismiss # Dismiss ```bash agent-browser trace start [path] # Start recording trace agent-browser trace stop [path] # Stop and save trace +agent-browser profiler start # Start Chrome DevTools profiling +agent-browser profiler stop [path] # Stop and save profile (.json) agent-browser console # View console messages (log, error, warn, info) agent-browser console --clear # Clear console agent-browser errors # View page errors (uncaught JavaScript exceptions) diff --git a/cli/src/commands.rs b/cli/src/commands.rs index abd4ef8..faa44f9 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -765,6 +765,43 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result { + const VALID: &[&str] = &["start", "stop"]; + match rest.first().copied() { + Some("start") => { + let mut cmd = json!({ "id": id, "action": "profiler_start" }); + if let Some(idx) = rest.iter().position(|s| *s == "--categories") { + if let Some(cats) = rest.get(idx + 1) { + let categories: Vec<&str> = cats.split(',').collect(); + cmd["categories"] = json!(categories); + } else { + return Err(ParseError::MissingArguments { + context: "profiler start --categories".to_string(), + usage: "--categories ", + }); + } + } + Ok(cmd) + } + Some("stop") => { + let mut cmd = json!({ "id": id, "action": "profiler_stop" }); + if let Some(path) = rest.get(1) { + cmd["path"] = json!(path); + } + Ok(cmd) + } + Some(sub) => Err(ParseError::UnknownSubcommand { + subcommand: sub.to_string(), + valid_options: VALID, + }), + None => Err(ParseError::MissingArguments { + context: "profiler".to_string(), + usage: "profiler [options]", + }), + } + } + // === Recording (Playwright native video recording) === "record" => { const VALID: &[&str] = &["start", "stop", "restart"]; @@ -2249,6 +2286,73 @@ mod tests { )); } + // === Profile (CDP Tracing) Tests === + + #[test] + fn test_profiler_start() { + let cmd = parse_command(&args("profiler start"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "profiler_start"); + assert!(cmd.get("categories").is_none()); + } + + #[test] + fn test_profiler_start_with_categories() { + let cmd = parse_command( + &args("profiler start --categories devtools.timeline,v8.execute"), + &default_flags(), + ) + .unwrap(); + assert_eq!(cmd["action"], "profiler_start"); + let categories = cmd["categories"].as_array().unwrap(); + assert_eq!(categories.len(), 2); + assert_eq!(categories[0], "devtools.timeline"); + assert_eq!(categories[1], "v8.execute"); + } + + #[test] + fn test_profiler_start_categories_missing_value() { + let result = parse_command(&args("profiler start --categories"), &default_flags()); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::MissingArguments { .. } + )); + } + + #[test] + fn test_profiler_stop_with_path() { + let cmd = parse_command(&args("profiler stop trace.json"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "profiler_stop"); + assert_eq!(cmd["path"], "trace.json"); + } + + #[test] + fn test_profiler_stop_no_path() { + let cmd = parse_command(&args("profiler stop"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "profiler_stop"); + assert!(cmd.get("path").is_none()); + } + + #[test] + fn test_profiler_invalid_subcommand() { + let result = parse_command(&args("profiler foo"), &default_flags()); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::UnknownSubcommand { .. } + )); + } + + #[test] + fn test_profiler_missing_subcommand() { + let result = parse_command(&args("profiler"), &default_flags()); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::MissingArguments { .. } + )); + } + // === Eval Tests === #[test] diff --git a/cli/src/output.rs b/cli/src/output.rs index 7daf60c..ab6321a 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -275,10 +275,21 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { // Recording start (has "started" field) if let Some(started) = data.get("started").and_then(|v| v.as_bool()) { if started { - if let Some(path) = data.get("path").and_then(|v| v.as_str()) { - println!("{} Recording started: {}", color::success_indicator(), path); - } else { - println!("{} Recording started", color::success_indicator()); + match action { + Some("profiler_start") => { + println!("{} Profiling started", color::success_indicator()); + } + _ => { + if let Some(path) = data.get("path").and_then(|v| v.as_str()) { + println!( + "{} Recording started: {}", + color::success_indicator(), + path + ); + } else { + println!("{} Recording started", color::success_indicator()); + } + } } return; } @@ -367,6 +378,12 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { color::success_indicator(), color::green(path) ), + "profiler_stop" => println!( + "{} Profile saved to {} ({} events)", + color::success_indicator(), + color::green(path), + data.get("eventCount").and_then(|c| c.as_u64()).unwrap_or(0) + ), "har_stop" => println!( "{} HAR saved to {}", color::success_indicator(), @@ -1471,6 +1488,46 @@ Examples: "## } + // === Profile (CDP Tracing) === + "profiler" => { + r##" +agent-browser profiler - Record Chrome DevTools performance profile + +Usage: agent-browser profiler [options] + +Record a performance profile using Chrome DevTools Protocol (CDP) Tracing. +The output JSON file can be loaded into Chrome DevTools Performance panel, +Perfetto UI (https://ui.perfetto.dev/), or other trace analysis tools. + +Operations: + start Start profiling + stop [path] Stop profiling and save to file + +Start Options: + --categories Comma-separated trace categories (default includes + devtools.timeline, v8.execute, blink, and others) + +Global Options: + --json Output as JSON + --session Use specific session + +Examples: + # Basic profiling + agent-browser profiler start + agent-browser navigate https://example.com + agent-browser click "#button" + agent-browser profiler stop ./trace.json + + # With custom categories + agent-browser profiler start --categories "devtools.timeline,v8.execute,blink.user_timing" + agent-browser profiler stop ./custom-trace.json + +The output file can be viewed in: + - Chrome DevTools: Performance panel > Load profile + - Perfetto: https://ui.perfetto.dev/ +"## + } + // === Record (video) === "record" => { r##" @@ -1833,7 +1890,8 @@ Tabs: tab [new|list|close|] Manage tabs Debug: - trace start|stop [path] Record trace + trace start|stop [path] Record Playwright trace + profiler start|stop [path] Record Chrome DevTools profile record start [url] Start video recording (WebM) record stop Stop and save video console [--clear] View console logs diff --git a/docs/src/app/commands/page.mdx b/docs/src/app/commands/page.mdx index 2111203..ce04efe 100644 --- a/docs/src/app/commands/page.mdx +++ b/docs/src/app/commands/page.mdx @@ -176,6 +176,8 @@ agent-browser dialog dismiss # Dismiss dialog ```bash agent-browser trace start [path] # Start trace agent-browser trace stop [path] # Stop and save trace +agent-browser profiler start # Start Chrome DevTools profiling +agent-browser profiler stop [path] # Stop and save profile (.json) agent-browser record start # Start video recording (WebM) agent-browser record stop # Stop and save video agent-browser record restart # Stop current and start new recording diff --git a/docs/src/app/profiler/page.mdx b/docs/src/app/profiler/page.mdx new file mode 100644 index 0000000..d0bd372 --- /dev/null +++ b/docs/src/app/profiler/page.mdx @@ -0,0 +1,102 @@ +export const metadata = { title: "Profiler" } + +# Profiler + +Capture Chrome DevTools performance profiles during browser automation. +Use profiles to diagnose slow page loads, expensive JavaScript, layout thrashing, +and other performance bottlenecks in agentic workflows. + +## Basic usage + +```bash +# Start profiling +agent-browser profiler start + +# Perform actions +agent-browser navigate https://example.com +agent-browser click "#button" + +# Stop and save profile +agent-browser profiler stop ./trace.json +``` + +The output JSON file can be loaded into Chrome DevTools, Perfetto UI, or any +tool that accepts Chrome Trace Event format. + +## Commands + +| Command | Description | +|---------|-------------| +| `profiler start` | Start recording a performance profile | +| `profiler start --categories ` | Start with custom trace categories | +| `profiler stop [path]` | Stop profiling and save to file | + +## Trace categories + +The `--categories` flag accepts a comma-separated list of Chrome trace categories. + +```bash +agent-browser profiler start --categories "devtools.timeline,v8.execute,blink.user_timing" +``` + +Default categories include `devtools.timeline`, `v8.execute`, `blink`, +`blink.user_timing`, `latencyInfo`, `renderer.scheduler`, `toplevel`, and +several `disabled-by-default-*` categories for detailed CPU profiling and +call stack analysis. + +### Common categories + +| Category | What it captures | +|----------|-----------------| +| `devtools.timeline` | Standard DevTools performance events | +| `v8.execute` | Time spent running JavaScript | +| `blink` | Renderer events (layout, paint, style) | +| `blink.user_timing` | `performance.mark()` and `performance.measure()` calls | +| `latencyInfo` | Input-to-display latency | +| `disabled-by-default-v8.cpu_profiler` | Sampling-based JS CPU profiling | + +## Output format + +The output is a JSON file in Chrome Trace Event format: + +```json +{ + "traceEvents": [ + { + "cat": "devtools.timeline", + "name": "RunTask", + "ph": "X", + "ts": 12345, + "dur": 100, + "pid": 1, + "tid": 1 + } + ], + "metadata": { + "clock-domain": "LINUX_CLOCK_MONOTONIC" + } +} +``` + +The `metadata.clock-domain` field reflects the host platform (Linux or macOS). +On Windows it is omitted. + +## Viewing profiles + +- **Chrome DevTools** -- Performance panel > Load profile +- **Perfetto** -- https://ui.perfetto.dev/ (drag and drop the JSON file) +- **Trace Viewer** -- `chrome://tracing` in any Chromium browser + +## Use cases + +- **Page load analysis** -- Profile navigation to identify slow resources, long tasks, or layout shifts +- **Interaction profiling** -- Measure the cost of clicks, form fills, and other user interactions +- **CI regression checks** -- Capture profiles per build and compare trace data over time +- **Agent workflow optimization** -- Find which steps in an agentic flow are most expensive + +## Limitations + +- Only works with Chromium-based browsers (Chrome, Edge). Not supported on Firefox or WebKit. +- Trace data accumulates in memory while profiling is active (capped at 5 million events). Stop profiling promptly after the area of interest. +- Data collection on stop has a 30-second timeout. If the browser is unresponsive, the stop command may fail. +- When no output path is provided, the profile is saved to an auto-generated path under the agent-browser temp directory. diff --git a/docs/src/lib/docs-navigation.ts b/docs/src/lib/docs-navigation.ts index 87adb01..24b3317 100644 --- a/docs/src/lib/docs-navigation.ts +++ b/docs/src/lib/docs-navigation.ts @@ -32,6 +32,7 @@ export const navigation: NavSection[] = [ { name: "Sessions", href: "/sessions" }, { name: "CDP Mode", href: "/cdp-mode" }, { name: "Streaming", href: "/streaming" }, + { name: "Profiler", href: "/profiler" }, { name: "iOS Simulator", href: "/ios" }, ], }, diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index 5f3e1b1..f5695cf 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -162,6 +162,8 @@ agent-browser --cdp 9222 snapshot agent-browser --headed open https://example.com agent-browser highlight @e1 # Highlight element agent-browser record start demo.webm # Record session +agent-browser profiler start # Start Chrome DevTools profiling +agent-browser profiler stop trace.json # Stop and save profile (path optional) ``` ### Local Files (PDFs, HTML) @@ -323,6 +325,7 @@ Priority (lowest to highest): `~/.agent-browser/config.json` < `./agent-browser. | [references/session-management.md](references/session-management.md) | Parallel sessions, state persistence, concurrent scraping | | [references/authentication.md](references/authentication.md) | Login flows, OAuth, 2FA handling, state reuse | | [references/video-recording.md](references/video-recording.md) | Recording workflows for debugging and documentation | +| [references/profiling.md](references/profiling.md) | Chrome DevTools profiling for performance analysis | | [references/proxy-support.md](references/proxy-support.md) | Proxy configuration, geo-testing, rotating proxies | ## Ready-to-Use Templates diff --git a/skills/agent-browser/references/commands.md b/skills/agent-browser/references/commands.md index 8d3452f..e77196c 100644 --- a/skills/agent-browser/references/commands.md +++ b/skills/agent-browser/references/commands.md @@ -247,6 +247,8 @@ agent-browser errors --clear # Clear errors agent-browser highlight @e1 # Highlight element agent-browser trace start # Start recording trace agent-browser trace stop trace.zip # Stop and save trace +agent-browser profiler start # Start Chrome DevTools profiling +agent-browser profiler stop trace.json # Stop and save profile ``` ## Environment Variables diff --git a/skills/agent-browser/references/profiling.md b/skills/agent-browser/references/profiling.md new file mode 100644 index 0000000..bd47eaa --- /dev/null +++ b/skills/agent-browser/references/profiling.md @@ -0,0 +1,120 @@ +# Profiling + +Capture Chrome DevTools performance profiles during browser automation for performance analysis. + +**Related**: [commands.md](commands.md) for full command reference, [SKILL.md](../SKILL.md) for quick start. + +## Contents + +- [Basic Profiling](#basic-profiling) +- [Profiler Commands](#profiler-commands) +- [Categories](#categories) +- [Use Cases](#use-cases) +- [Output Format](#output-format) +- [Viewing Profiles](#viewing-profiles) +- [Limitations](#limitations) + +## Basic Profiling + +```bash +# Start profiling +agent-browser profiler start + +# Perform actions +agent-browser navigate https://example.com +agent-browser click "#button" +agent-browser wait 1000 + +# Stop and save +agent-browser profiler stop ./trace.json +``` + +## Profiler Commands + +```bash +# Start profiling with default categories +agent-browser profiler start + +# Start with custom trace categories +agent-browser profiler start --categories "devtools.timeline,v8.execute,blink.user_timing" + +# Stop profiling and save to file +agent-browser profiler stop ./trace.json +``` + +## Categories + +The `--categories` flag accepts a comma-separated list of Chrome trace categories. Default categories include: + +- `devtools.timeline` -- standard DevTools performance traces +- `v8.execute` -- time spent running JavaScript +- `blink` -- renderer events +- `blink.user_timing` -- `performance.mark()` / `performance.measure()` calls +- `latencyInfo` -- input-to-latency tracking +- `renderer.scheduler` -- task scheduling and execution +- `toplevel` -- broad-spectrum basic events + +Several `disabled-by-default-*` categories are also included for detailed timeline, call stack, and V8 CPU profiling data. + +## Use Cases + +### Diagnosing Slow Page Loads + +```bash +agent-browser profiler start +agent-browser navigate https://app.example.com +agent-browser wait --load networkidle +agent-browser profiler stop ./page-load-profile.json +``` + +### Profiling User Interactions + +```bash +agent-browser navigate https://app.example.com +agent-browser profiler start +agent-browser click "#submit" +agent-browser wait 2000 +agent-browser profiler stop ./interaction-profile.json +``` + +### CI Performance Regression Checks + +```bash +#!/bin/bash +agent-browser profiler start +agent-browser navigate https://app.example.com +agent-browser wait --load networkidle +agent-browser profiler stop "./profiles/build-${BUILD_ID}.json" +``` + +## Output Format + +The output is a JSON file in Chrome Trace Event format: + +```json +{ + "traceEvents": [ + { "cat": "devtools.timeline", "name": "RunTask", "ph": "X", "ts": 12345, "dur": 100, ... }, + ... + ], + "metadata": { + "clock-domain": "LINUX_CLOCK_MONOTONIC" + } +} +``` + +The `metadata.clock-domain` field is set based on the host platform (Linux or macOS). On Windows it is omitted. + +## Viewing Profiles + +Load the output JSON file in any of these tools: + +- **Chrome DevTools**: Performance panel > Load profile (Ctrl+Shift+I > Performance) +- **Perfetto UI**: https://ui.perfetto.dev/ -- drag and drop the JSON file +- **Trace Viewer**: `chrome://tracing` in any Chromium browser + +## Limitations + +- Only works with Chromium-based browsers (Chrome, Edge). Not supported on Firefox or WebKit. +- Trace data accumulates in memory while profiling is active (capped at 5 million events). Stop profiling promptly after the area of interest. +- Data collection on stop has a 30-second timeout. If the browser is unresponsive, the stop command may fail. diff --git a/src/actions.ts b/src/actions.ts index 1ec9b5f..5490fa0 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -65,6 +65,8 @@ import type { StylesCommand, TraceStartCommand, TraceStopCommand, + ProfilerStartCommand, + ProfilerStopCommand, HarStopCommand, StorageStateSaveCommand, StateListCommand, @@ -355,6 +357,10 @@ export async function executeCommand(command: Command, browser: BrowserManager): return await handleTraceStart(command, browser); case 'trace_stop': return await handleTraceStop(command, browser); + case 'profiler_start': + return await handleProfilerStart(command, browser); + case 'profiler_stop': + return await handleProfilerStop(command, browser); case 'har_start': return await handleHarStart(command, browser); case 'har_stop': @@ -1448,6 +1454,31 @@ async function handleTraceStop( ); } +async function handleProfilerStart( + command: ProfilerStartCommand, + browser: BrowserManager +): Promise { + await browser.startProfiling({ categories: command.categories }); + return successResponse(command.id, { started: true }); +} + +async function handleProfilerStop( + command: ProfilerStopCommand, + browser: BrowserManager +): Promise { + let outputPath = command.path; + if (!outputPath) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const random = Math.random().toString(36).substring(2, 8); + const filename = `profile-${timestamp}-${random}.json`; + const profileDir = path.join(getAppDir(), 'tmp', 'profiles'); + mkdirSync(profileDir, { recursive: true }); + outputPath = path.join(profileDir, filename); + } + const result = await browser.stopProfiling(outputPath); + return successResponse(command.id, result); +} + async function handleHarStart( command: Command & { action: 'har_start' }, browser: BrowserManager diff --git a/src/browser.test.ts b/src/browser.test.ts index fa7462d..f83d5e5 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -866,6 +866,83 @@ describe('BrowserManager', () => { }); }); + describe('profiling (CDP tracing)', () => { + const fs = require('node:fs/promises'); + const path = require('node:path'); + const testOutputDir = '/tmp/agent-browser-test'; + + beforeAll(async () => { + // Ensure test output directory exists + await fs.mkdir(testOutputDir, { recursive: true }).catch(() => {}); + }); + + afterEach(async () => { + // Stop profiling if still active + if (browser.isProfilingActive()) { + const tempPath = path.join(testOutputDir, 'cleanup.json'); + await browser.stopProfiling(tempPath).catch(() => {}); + } + }); + + it('should report profiling state correctly', () => { + expect(browser.isProfilingActive()).toBe(false); + }); + + it('should start profiling', async () => { + await browser.startProfiling(); + expect(browser.isProfilingActive()).toBe(true); + }); + + it('should throw when starting profiling twice', async () => { + await browser.startProfiling(); + await expect(browser.startProfiling()).rejects.toThrow('Profiling already active'); + }); + + it('should stop profiling and write file', async () => { + await browser.startProfiling(); + + const outputPath = path.join(testOutputDir, 'test-profile.json'); + const result = await browser.stopProfiling(outputPath); + + expect(result.path).toBe(outputPath); + expect(typeof result.eventCount).toBe('number'); + expect(browser.isProfilingActive()).toBe(false); + + // Verify file was written + const fileExists = await fs + .access(outputPath) + .then(() => true) + .catch(() => false); + expect(fileExists).toBe(true); + + // Verify file content is valid JSON with traceEvents + const content = await fs.readFile(outputPath, 'utf-8'); + const data = JSON.parse(content); + expect(data).toHaveProperty('traceEvents'); + expect(Array.isArray(data.traceEvents)).toBe(true); + + // Cleanup + await fs.unlink(outputPath).catch(() => {}); + }); + + it('should throw when stopping without start', async () => { + expect(browser.isProfilingActive()).toBe(false); + await expect(browser.stopProfiling('/tmp/should-not-exist.json')).rejects.toThrow( + 'No profiling session active' + ); + }); + + it('should start profiling with custom categories', async () => { + await browser.startProfiling({ categories: ['devtools.timeline', 'v8.execute'] }); + expect(browser.isProfilingActive()).toBe(true); + + // Stop and cleanup + const outputPath = path.join(testOutputDir, 'custom-categories.json'); + await browser.stopProfiling(outputPath); + await fs.unlink(outputPath).catch(() => {}); + }); + }); + describe('input injection', () => { it('should inject mouse move event', async () => { await expect( diff --git a/src/browser.ts b/src/browser.ts index 5205355..98135c3 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -17,7 +17,8 @@ import { import path from 'node:path'; import os from 'node:os'; import { existsSync, mkdirSync, rmSync, readFileSync } from 'node:fs'; -import type { LaunchCommand } from './types.js'; +import { writeFile, mkdir } from 'node:fs/promises'; +import type { LaunchCommand, TraceEvent } from './types.js'; import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js'; import { safeHeaderMerge } from './state-utils.js'; import { @@ -120,6 +121,15 @@ export class BrowserManager { return warnings; } + // CDP profiling state + private static readonly MAX_PROFILE_EVENTS = 5_000_000; + private profilingActive: boolean = false; + private profileChunks: TraceEvent[] = []; + private profileEventsDropped: boolean = false; + private profileCompleteResolver: (() => void) | null = null; + private profileDataHandler: ((params: { value?: TraceEvent[] }) => void) | null = null; + private profileCompleteHandler: (() => void) | null = null; + /** * Check if browser is launched */ @@ -1818,6 +1828,156 @@ export class BrowserManager { this.screencastFrameHandler = null; } + /** + * Check if profiling is currently active + */ + isProfilingActive(): boolean { + return this.profilingActive; + } + + /** + * Start CDP profiling (Tracing) + */ + async startProfiling(options?: { categories?: string[] }): Promise { + if (this.profilingActive) { + throw new Error('Profiling already active'); + } + + const cdp = await this.getCDPSession(); + + const dataHandler = (params: { value?: TraceEvent[] }) => { + if (params.value) { + for (const evt of params.value) { + if (this.profileChunks.length >= BrowserManager.MAX_PROFILE_EVENTS) { + if (!this.profileEventsDropped) { + this.profileEventsDropped = true; + console.warn( + `Profiling: exceeded ${BrowserManager.MAX_PROFILE_EVENTS} events, dropping further data` + ); + } + return; + } + this.profileChunks.push(evt); + } + } + }; + + const completeHandler = () => { + if (this.profileCompleteResolver) { + this.profileCompleteResolver(); + } + }; + + cdp.on('Tracing.dataCollected', dataHandler); + cdp.on('Tracing.tracingComplete', completeHandler); + + const categories = options?.categories ?? [ + 'devtools.timeline', + 'disabled-by-default-devtools.timeline', + 'disabled-by-default-devtools.timeline.frame', + 'disabled-by-default-devtools.timeline.stack', + 'v8.execute', + 'disabled-by-default-v8.cpu_profiler', + 'disabled-by-default-v8.cpu_profiler.hires', + 'v8', + 'disabled-by-default-v8.runtime_stats', + 'blink', + 'blink.user_timing', + 'latencyInfo', + 'renderer.scheduler', + 'sequence_manager', + 'toplevel', + ]; + + try { + await cdp.send('Tracing.start', { + traceConfig: { + includedCategories: categories, + enableSampling: true, + }, + transferMode: 'ReportEvents', + }); + } catch (error) { + cdp.off('Tracing.dataCollected', dataHandler); + cdp.off('Tracing.tracingComplete', completeHandler); + throw error; + } + + // Only commit state after the CDP call succeeds + this.profilingActive = true; + this.profileChunks = []; + this.profileEventsDropped = false; + this.profileDataHandler = dataHandler; + this.profileCompleteHandler = completeHandler; + } + + /** + * Stop CDP profiling and save to file + */ + async stopProfiling(outputPath: string): Promise<{ path: string; eventCount: number }> { + if (!this.profilingActive) { + throw new Error('No profiling session active'); + } + + const cdp = await this.getCDPSession(); + + const TRACE_TIMEOUT_MS = 30_000; + const completePromise = new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('Profiling data collection timed out')), + TRACE_TIMEOUT_MS + ); + this.profileCompleteResolver = () => { + clearTimeout(timer); + resolve(); + }; + }); + + await cdp.send('Tracing.end'); + + let chunks: TraceEvent[]; + try { + await completePromise; + chunks = this.profileChunks; + } finally { + if (this.profileDataHandler) { + cdp.off('Tracing.dataCollected', this.profileDataHandler); + } + if (this.profileCompleteHandler) { + cdp.off('Tracing.tracingComplete', this.profileCompleteHandler); + } + this.profilingActive = false; + this.profileChunks = []; + this.profileEventsDropped = false; + this.profileCompleteResolver = null; + this.profileDataHandler = null; + this.profileCompleteHandler = null; + } + + const clockDomain = + process.platform === 'linux' + ? 'LINUX_CLOCK_MONOTONIC' + : process.platform === 'darwin' + ? 'MAC_MACH_ABSOLUTE_TIME' + : undefined; + + const traceData: Record = { + traceEvents: chunks, + }; + if (clockDomain) { + traceData.metadata = { 'clock-domain': clockDomain }; + } + + const dir = path.dirname(outputPath); + await mkdir(dir, { recursive: true }); + + await writeFile(outputPath, JSON.stringify(traceData)); + + const eventCount = chunks.length; + + return { path: outputPath, eventCount }; + } + /** * Inject a mouse event via CDP */ @@ -2131,6 +2291,26 @@ export class BrowserManager { await this.stopScreencast(); } + // Clean up profiling state if active (without saving) + if (this.profilingActive) { + const cdp = this.cdpSession; + if (cdp) { + if (this.profileDataHandler) { + cdp.off('Tracing.dataCollected', this.profileDataHandler); + } + if (this.profileCompleteHandler) { + cdp.off('Tracing.tracingComplete', this.profileCompleteHandler); + } + await cdp.send('Tracing.end').catch(() => {}); + } + this.profilingActive = false; + this.profileChunks = []; + this.profileEventsDropped = false; + this.profileCompleteResolver = null; + this.profileDataHandler = null; + this.profileCompleteHandler = null; + } + // Clean up CDP session if (this.cdpSession) { await this.cdpSession.detach().catch(() => {}); diff --git a/src/protocol.ts b/src/protocol.ts index ce5e693..9b72c6a 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -374,6 +374,16 @@ const traceStopSchema = baseCommandSchema.extend({ path: z.string().min(1).optional(), }); +const profilerStartSchema = baseCommandSchema.extend({ + action: z.literal('profiler_start'), + categories: z.array(z.string()).optional(), +}); + +const profilerStopSchema = baseCommandSchema.extend({ + action: z.literal('profiler_stop'), + path: z.string().min(1).optional(), +}); + const harStartSchema = baseCommandSchema.extend({ action: z.literal('har_start'), }); @@ -898,6 +908,8 @@ const commandSchema = z.discriminatedUnion('action', [ recordingRestartSchema, traceStartSchema, traceStopSchema, + profilerStartSchema, + profilerStopSchema, harStartSchema, harStopSchema, stateSaveSchema, diff --git a/src/types.ts b/src/types.ts index 9f672d8..8f20bc4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -579,6 +579,33 @@ export interface TraceStopCommand extends BaseCommand { path?: string; } +/** + * Chrome Trace Event format. All fields are optional because CDP trace event + * shapes vary across categories and event phases -- this type is intentionally + * loose to accept any valid trace event without data loss. + */ +export interface TraceEvent { + cat?: string; + name?: string; + ph?: string; + pid?: number; + tid?: number; + ts?: number; + dur?: number; + args?: Record; + [key: string]: unknown; +} + +export interface ProfilerStartCommand extends BaseCommand { + action: 'profiler_start'; + categories?: string[]; // Optional trace categories (e.g., "devtools.timeline") +} + +export interface ProfilerStopCommand extends BaseCommand { + action: 'profiler_stop'; + path?: string; +} + // HAR recording export interface HarStartCommand extends BaseCommand { action: 'har_start'; @@ -924,6 +951,8 @@ export type Command = | RecordingRestartCommand | TraceStartCommand | TraceStopCommand + | ProfilerStartCommand + | ProfilerStopCommand | HarStartCommand | HarStopCommand | StorageStateSaveCommand