diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 46d879a..b13b5e2 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -443,6 +443,60 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result { + const VALID: &[&str] = &["start", "stop", "restart"]; + match rest.get(0).map(|s| *s) { + Some("start") => { + let path = rest.get(1).ok_or_else(|| ParseError::MissingArguments { + context: "record start".to_string(), + usage: "record start [url]", + })?; + // Optional URL parameter + let url = rest.get(2); + let mut cmd = json!({ "id": id, "action": "recording_start", "path": path }); + if let Some(u) = url { + // Add https:// prefix if needed + let url_str = if u.starts_with("http") { + u.to_string() + } else { + format!("https://{}", u) + }; + cmd["url"] = json!(url_str); + } + Ok(cmd) + } + Some("stop") => Ok(json!({ "id": id, "action": "recording_stop" })), + Some("restart") => { + let path = rest.get(1).ok_or_else(|| ParseError::MissingArguments { + context: "record restart".to_string(), + usage: "record restart [url]", + })?; + // Optional URL parameter + let url = rest.get(2); + let mut cmd = json!({ "id": id, "action": "recording_restart", "path": path }); + if let Some(u) = url { + // Add https:// prefix if needed + let url_str = if u.starts_with("http") { + u.to_string() + } else { + format!("https://{}", u) + }; + cmd["url"] = json!(url_str); + } + Ok(cmd) + } + Some(sub) => Err(ParseError::UnknownSubcommand { + subcommand: sub.to_string(), + valid_options: VALID, + }), + None => Err(ParseError::MissingArguments { + context: "record".to_string(), + usage: "record [path] [url]", + }), + } + } "console" => { let clear = rest.iter().any(|&s| s == "--clear"); Ok(json!({ "id": id, "action": "console", "clear": clear })) @@ -1274,6 +1328,82 @@ mod tests { // === Unknown command === + // === Record Tests === + + #[test] + fn test_record_start() { + let cmd = parse_command(&args("record start output.webm"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "recording_start"); + assert_eq!(cmd["path"], "output.webm"); + assert!(cmd.get("url").is_none()); + } + + #[test] + fn test_record_start_with_url() { + let cmd = parse_command(&args("record start demo.webm https://example.com"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "recording_start"); + assert_eq!(cmd["path"], "demo.webm"); + assert_eq!(cmd["url"], "https://example.com"); + } + + #[test] + fn test_record_start_with_url_no_protocol() { + let cmd = parse_command(&args("record start demo.webm example.com"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "recording_start"); + assert_eq!(cmd["path"], "demo.webm"); + assert_eq!(cmd["url"], "https://example.com"); + } + + #[test] + fn test_record_start_missing_path() { + let result = parse_command(&args("record start"), &default_flags()); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), ParseError::MissingArguments { .. })); + } + + #[test] + fn test_record_stop() { + let cmd = parse_command(&args("record stop"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "recording_stop"); + } + + #[test] + fn test_record_restart() { + let cmd = parse_command(&args("record restart output.webm"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "recording_restart"); + assert_eq!(cmd["path"], "output.webm"); + assert!(cmd.get("url").is_none()); + } + + #[test] + fn test_record_restart_with_url() { + let cmd = parse_command(&args("record restart demo.webm https://example.com"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "recording_restart"); + assert_eq!(cmd["path"], "demo.webm"); + assert_eq!(cmd["url"], "https://example.com"); + } + + #[test] + fn test_record_restart_missing_path() { + let result = parse_command(&args("record restart"), &default_flags()); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), ParseError::MissingArguments { .. })); + } + + #[test] + fn test_record_invalid_subcommand() { + let result = parse_command(&args("record foo"), &default_flags()); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), ParseError::UnknownSubcommand { .. })); + } + + #[test] + fn test_record_missing_subcommand() { + let result = parse_command(&args("record"), &default_flags()); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), ParseError::MissingArguments { .. })); + } + #[test] fn test_unknown_command() { let result = parse_command(&args("unknowncommand"), &default_flags()); diff --git a/cli/src/output.rs b/cli/src/output.rs index d63af2e..3a56cc1 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -135,7 +135,41 @@ pub fn print_response(resp: &Response, json_mode: bool) { println!("\x1b[32m✓\x1b[0m Browser closed"); return; } - // Screenshot path + // 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!("\x1b[32m✓\x1b[0m Recording started: {}", path); + } else { + println!("\x1b[32m✓\x1b[0m Recording started"); + } + return; + } + } + // Recording restart (has "stopped" field - from recording_restart action) + if data.get("stopped").is_some() { + let path = data.get("path").and_then(|v| v.as_str()).unwrap_or("unknown"); + if let Some(prev_path) = data.get("previousPath").and_then(|v| v.as_str()) { + println!("\x1b[32m✓\x1b[0m Recording restarted: {} (previous saved to {})", path, prev_path); + } else { + println!("\x1b[32m✓\x1b[0m Recording started: {}", path); + } + return; + } + // Recording stop (has "frames" field - from recording_stop action) + if data.get("frames").is_some() { + if let Some(path) = data.get("path").and_then(|v| v.as_str()) { + if let Some(error) = data.get("error").and_then(|v| v.as_str()) { + println!("\x1b[33m⚠\x1b[0m Recording saved to {} - {}", path, error); + } else { + println!("\x1b[32m✓\x1b[0m Recording saved to {}", path); + } + } else { + println!("\x1b[32m✓\x1b[0m Recording stopped"); + } + return; + } + // Screenshot path (no "started" or "frames" field) if let Some(path) = data.get("path").and_then(|v| v.as_str()) { println!("\x1b[32m✓\x1b[0m Screenshot saved to {}", path); return; @@ -979,6 +1013,42 @@ Examples: agent-browser trace stop ./debug-trace.zip "##, + // === Record (video) === + "record" => r##" +agent-browser record - Record browser session to video + +Usage: agent-browser record start [url] + agent-browser record stop + agent-browser record restart [url] + +Record the browser to a WebM video file using Playwright's native recording. +Creates a fresh browser context but preserves cookies and localStorage. +If no URL is provided, automatically navigates to your current page. + +Operations: + start [url] Start recording (defaults to current URL if omitted) + stop Stop recording and save video + restart [url] Stop current recording (if any) and start a new one + +Global Options: + --json Output as JSON + --session Use specific session + +Examples: + # Record from current page (preserves login state) + agent-browser open https://app.example.com/dashboard + agent-browser snapshot -i # Explore and plan + agent-browser record start ./demo.webm + agent-browser click @e3 # Execute planned actions + agent-browser record stop + + # Or specify a different URL + agent-browser record start ./demo.webm https://example.com + + # Restart recording with a new file (stops previous, starts new) + agent-browser record restart ./take2.webm +"##, + // === Console/Errors === "console" => r##" agent-browser console - View console logs @@ -1169,6 +1239,8 @@ Tabs: Debug: trace start|stop [path] Record trace + record start [url] Start video recording (WebM) + record stop Stop and save video console [--clear] View console logs errors [--clear] View page errors highlight Highlight element diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index 5557de7..ab28f5e 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -90,6 +90,15 @@ agent-browser screenshot --full # Full page agent-browser pdf output.pdf # Save as PDF ``` +### Video recording +```bash +agent-browser record start ./demo.webm # Start recording (uses current URL + state) +agent-browser click @e1 # Perform actions +agent-browser record stop # Stop and save video +agent-browser record restart ./take2.webm # Stop current + start new recording +``` +Recording creates a fresh context but preserves cookies/storage from your session. If no URL is provided, it automatically returns to your current page. For smooth demos, explore first, then start recording. + ### Wait ```bash agent-browser wait @e1 # Wait for element @@ -225,6 +234,11 @@ agent-browser get text @e1 --json ## Debugging ```bash +agent-browser open example.com --headed # Show browser window +agent-browser console # View console messages +agent-browser errors # View page errors +agent-browser record start ./debug.webm # Record from current page +agent-browser record stop # Save recording agent-browser open example.com --headed # Show browser window agent-browser --cdp 9222 snapshot # Connect via CDP agent-browser console # View console messages diff --git a/src/actions.ts b/src/actions.ts index 50f540d..66a2388 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -99,6 +99,9 @@ import type { InputMouseCommand, InputKeyboardCommand, InputTouchCommand, + RecordingStartCommand, + RecordingStopCommand, + RecordingRestartCommand, NavigateData, ScreenshotData, EvaluateData, @@ -109,6 +112,9 @@ import type { TabCloseData, ScreencastStartData, ScreencastStopData, + RecordingStartData, + RecordingStopData, + RecordingRestartData, InputEventData, } from './types.js'; import { successResponse, errorResponse } from './protocol.js'; @@ -427,6 +433,12 @@ export async function executeCommand(command: Command, browser: BrowserManager): return await handleInputKeyboard(command, browser); case 'input_touch': return await handleInputTouch(command, browser); + case 'recording_start': + return await handleRecordingStart(command, browser); + case 'recording_stop': + return await handleRecordingStop(command, browser); + case 'recording_restart': + return await handleRecordingRestart(command, browser); default: { // TypeScript narrows to never here, but we handle it for safety const unknownCommand = command as { id: string; action: string }; @@ -1886,3 +1898,37 @@ async function handleInputTouch( }); return successResponse(command.id, { injected: true }); } + +// Recording handlers (Playwright native video recording) + +async function handleRecordingStart( + command: RecordingStartCommand, + browser: BrowserManager +): Promise> { + await browser.startRecording(command.path, command.url); + return successResponse(command.id, { + started: true, + path: command.path, + }); +} + +async function handleRecordingStop( + command: RecordingStopCommand, + browser: BrowserManager +): Promise> { + const result = await browser.stopRecording(); + return successResponse(command.id, result); +} + +async function handleRecordingRestart( + command: RecordingRestartCommand, + browser: BrowserManager +): Promise> { + const result = await browser.restartRecording(command.path, command.url); + return successResponse(command.id, { + started: true, + path: command.path, + previousPath: result.previousPath, + stopped: result.stopped, + }); +} diff --git a/src/browser.ts b/src/browser.ts index 9e4a109..2f887b9 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -12,9 +12,11 @@ import { type Route, type Locator, type CDPSession, + type Video, } from 'playwright-core'; import path from 'node:path'; import os from 'node:os'; +import { existsSync, mkdirSync, rmSync } from 'node:fs'; import type { LaunchCommand } from './types.js'; import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js'; @@ -89,6 +91,12 @@ export class BrowserManager { private frameCallback: ((frame: ScreencastFrame) => void) | null = null; private screencastFrameHandler: ((params: any) => void) | null = null; + // Video recording (Playwright native) + private recordingContext: BrowserContext | null = null; + private recordingPage: Page | null = null; + private recordingOutputPath: string = ''; + private recordingTempDir: string = ''; + /** * Check if browser is launched */ @@ -1108,10 +1116,236 @@ export class BrowserManager { }); } + /** + * Check if video recording is currently active + */ + isRecording(): boolean { + return this.recordingContext !== null; + } + + /** + * Start recording to a video file using Playwright's native video recording. + * Creates a fresh browser context with video recording enabled. + * Automatically captures current URL and transfers cookies/storage if no URL provided. + * + * @param outputPath - Path to the output video file (will be .webm) + * @param url - Optional URL to navigate to (defaults to current page URL) + */ + async startRecording(outputPath: string, url?: string): Promise { + if (this.recordingContext) { + throw new Error( + "Recording already in progress. Run 'record stop' first, or use 'record restart' to stop and start a new recording." + ); + } + + if (!this.browser) { + throw new Error('Browser not launched. Call launch first.'); + } + + // Check if output file already exists + if (existsSync(outputPath)) { + throw new Error(`Output file already exists: ${outputPath}`); + } + + // Validate output path is .webm (Playwright native format) + if (!outputPath.endsWith('.webm')) { + throw new Error( + 'Playwright native recording only supports WebM format. Please use a .webm extension.' + ); + } + + // Auto-capture current URL if none provided + const currentPage = this.pages.length > 0 ? this.pages[this.activePageIndex] : null; + const currentContext = this.contexts.length > 0 ? this.contexts[0] : null; + if (!url && currentPage) { + const currentUrl = currentPage.url(); + if (currentUrl && currentUrl !== 'about:blank') { + url = currentUrl; + } + } + + // Capture state from current context (cookies + storage) + let storageState: + | { + cookies: Array<{ + name: string; + value: string; + domain: string; + path: string; + expires: number; + httpOnly: boolean; + secure: boolean; + sameSite: 'Strict' | 'Lax' | 'None'; + }>; + origins: Array<{ + origin: string; + localStorage: Array<{ name: string; value: string }>; + }>; + } + | undefined; + + if (currentContext) { + try { + storageState = await currentContext.storageState(); + } catch { + // Ignore errors - context might be closed or invalid + } + } + + // Create a temp directory for video recording + const session = process.env.AGENT_BROWSER_SESSION || 'default'; + this.recordingTempDir = path.join( + os.tmpdir(), + `agent-browser-recording-${session}-${Date.now()}` + ); + mkdirSync(this.recordingTempDir, { recursive: true }); + + this.recordingOutputPath = outputPath; + + // Create a new context with video recording enabled and restored state + const viewport = { width: 1280, height: 720 }; + this.recordingContext = await this.browser.newContext({ + viewport, + recordVideo: { + dir: this.recordingTempDir, + size: viewport, + }, + storageState, + }); + this.recordingContext.setDefaultTimeout(10000); + + // Create a page in the recording context + this.recordingPage = await this.recordingContext.newPage(); + + // Add the recording context and page to our managed lists + this.contexts.push(this.recordingContext); + this.pages.push(this.recordingPage); + this.activePageIndex = this.pages.length - 1; + + // Set up page tracking + this.setupPageTracking(this.recordingPage); + + // Invalidate CDP session since we switched pages + await this.invalidateCDPSession(); + + // Navigate to URL if provided or captured + if (url) { + await this.recordingPage.goto(url, { waitUntil: 'load' }); + } + } + + /** + * Stop recording and save the video file + * @returns Recording result with path + */ + async stopRecording(): Promise<{ path: string; frames: number; error?: string }> { + if (!this.recordingContext || !this.recordingPage) { + return { path: '', frames: 0, error: 'No recording in progress' }; + } + + const outputPath = this.recordingOutputPath; + + try { + // Get the video object before closing the page + const video = this.recordingPage.video(); + + // Remove recording page/context from our managed lists before closing + const pageIndex = this.pages.indexOf(this.recordingPage); + if (pageIndex !== -1) { + this.pages.splice(pageIndex, 1); + } + const contextIndex = this.contexts.indexOf(this.recordingContext); + if (contextIndex !== -1) { + this.contexts.splice(contextIndex, 1); + } + + // Close the page to finalize the video + await this.recordingPage.close(); + + // Save the video to the desired output path + if (video) { + await video.saveAs(outputPath); + } + + // Clean up temp directory + if (this.recordingTempDir) { + rmSync(this.recordingTempDir, { recursive: true, force: true }); + } + + // Close the recording context + await this.recordingContext.close(); + + // Reset recording state + this.recordingContext = null; + this.recordingPage = null; + this.recordingOutputPath = ''; + this.recordingTempDir = ''; + + // Adjust active page index + if (this.pages.length > 0) { + this.activePageIndex = Math.min(this.activePageIndex, this.pages.length - 1); + } else { + this.activePageIndex = 0; + } + + // Invalidate CDP session since we may have switched pages + await this.invalidateCDPSession(); + + return { path: outputPath, frames: 0 }; // Playwright doesn't expose frame count + } catch (error) { + // Clean up temp directory on error + if (this.recordingTempDir) { + rmSync(this.recordingTempDir, { recursive: true, force: true }); + } + + // Reset state on error + this.recordingContext = null; + this.recordingPage = null; + this.recordingOutputPath = ''; + this.recordingTempDir = ''; + + const message = error instanceof Error ? error.message : String(error); + return { path: outputPath, frames: 0, error: message }; + } + } + + /** + * Restart recording - stops current recording (if any) and starts a new one. + * Convenience method that combines stopRecording and startRecording. + * + * @param outputPath - Path to the output video file (must be .webm) + * @param url - Optional URL to navigate to (defaults to current page URL) + * @returns Result from stopping the previous recording (if any) + */ + async restartRecording( + outputPath: string, + url?: string + ): Promise<{ previousPath?: string; stopped: boolean }> { + let previousPath: string | undefined; + let stopped = false; + + // Stop current recording if active + if (this.recordingContext) { + const result = await this.stopRecording(); + previousPath = result.path; + stopped = true; + } + + // Start new recording + await this.startRecording(outputPath, url); + + return { previousPath, stopped }; + } + /** * Close the browser and clean up */ async close(): Promise { + // Stop recording if active (saves video) + if (this.recordingContext) { + await this.stopRecording(); + } + // Stop screencast if active if (this.screencastActive) { await this.stopScreencast(); diff --git a/src/protocol.ts b/src/protocol.ts index dc79f34..bf4bb2d 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -315,6 +315,23 @@ const videoStopSchema = baseCommandSchema.extend({ action: z.literal('video_stop'), }); +// Recording schemas (Playwright native video recording) +const recordingStartSchema = baseCommandSchema.extend({ + action: z.literal('recording_start'), + path: z.string().min(1), + url: z.string().min(1).optional(), +}); + +const recordingStopSchema = baseCommandSchema.extend({ + action: z.literal('recording_stop'), +}); + +const recordingRestartSchema = baseCommandSchema.extend({ + action: z.literal('recording_restart'), + path: z.string().min(1), + url: z.string().min(1).optional(), +}); + const traceStartSchema = baseCommandSchema.extend({ action: z.literal('trace_start'), screenshots: z.boolean().optional(), @@ -804,6 +821,9 @@ const commandSchema = z.discriminatedUnion('action', [ boundingBoxSchema, videoStartSchema, videoStopSchema, + recordingStartSchema, + recordingStopSchema, + recordingRestartSchema, traceStartSchema, traceStopSchema, harStartSchema, diff --git a/src/types.ts b/src/types.ts index 1201fdd..07da4f0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -508,7 +508,7 @@ export interface InputTouchCommand extends BaseCommand { modifiers?: number; } -// Video recording +// Video recording (Playwright native - requires launch-time setup) export interface VideoStartCommand extends BaseCommand { action: 'video_start'; path: string; @@ -518,6 +518,23 @@ export interface VideoStopCommand extends BaseCommand { action: 'video_stop'; } +// Screen recording (Playwright native - creates fresh recording context) +export interface RecordingStartCommand extends BaseCommand { + action: 'recording_start'; + path: string; + url?: string; +} + +export interface RecordingStopCommand extends BaseCommand { + action: 'recording_stop'; +} + +export interface RecordingRestartCommand extends BaseCommand { + action: 'recording_restart'; + path: string; + url?: string; +} + // Tracing export interface TraceStartCommand extends BaseCommand { action: 'trace_start'; @@ -841,6 +858,9 @@ export type Command = | BoundingBoxCommand | VideoStartCommand | VideoStopCommand + | RecordingStartCommand + | RecordingStopCommand + | RecordingRestartCommand | TraceStartCommand | TraceStopCommand | HarStartCommand @@ -974,6 +994,24 @@ export interface ScreencastStopData { stopped: boolean; } +export interface RecordingStartData { + started: boolean; + path: string; +} + +export interface RecordingStopData { + path: string; + frames: number; + error?: string; +} + +export interface RecordingRestartData { + started: boolean; + path: string; + previousPath?: string; + stopped: boolean; +} + export interface InputEventData { injected: boolean; }