feat: Add video recording with Playwright native video (#116)

* feat: add video recording with Playwright native video

Adds `record start/stop` commands using Playwright's built-in video
recording. No external dependencies required (no FFmpeg).

Usage:
  agent-browser record start ./demo.webm https://example.com
  agent-browser click @e1
  agent-browser record stop

Recording creates a fresh browser context with video enabled. For smooth
demos, explore the page first to plan actions, then start recording.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: auto-capture URL and transfer state for recording

When starting a recording without a URL:
- Automatically captures current page URL
- Preserves cookies and localStorage from current session

This enables a seamless workflow:
  agent-browser open https://app.example.com
  agent-browser snapshot -i  # explore, plan
  agent-browser record start ./demo.webm  # picks up URL + auth state
  agent-browser click @e3
  agent-browser record stop

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: error on non-webm recording path instead of silent coercion

Previously, specifying a non-.webm path like ./demo.mp4 would silently
change it to ./demo.webm. Now it throws a clear error telling the user
that Playwright native recording only supports WebM format.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: clean up recording temp directory after stopRecording

Previously the temp directory was created but never deleted, relying on
OS cleanup. Now we explicitly remove it after saving the video, in both
success and error paths.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add record restart command

Adds `record restart` command that stops the current recording (if any)
and starts a new one. Also improves the error message when trying to
start recording while already recording.

Changes:
- Add restartRecording method to BrowserManager
- Add recording_restart action to protocol, types, and actions
- Add CLI parsing for `record restart <path> [url]`
- Update help text and skill documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: add CLI tests for record restart command

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Chris Tate <chris@ctate.dev>
This commit is contained in:
Dave Kiss
2026-01-16 12:27:58 -06:00
committed by GitHub
co-authored by Claude Opus 4.5 Chris Tate
parent 3675e6bd7a
commit 1f31452fea
7 changed files with 556 additions and 2 deletions
+130
View File
@@ -443,6 +443,60 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}),
}
}
// === Recording (Playwright native video recording) ===
"record" => {
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 <output.webm> [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 <output.webm> [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 <start|stop|restart> [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());
+73 -1
View File
@@ -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 <path.webm> [url]
agent-browser record stop
agent-browser record restart <path.webm> [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 <path> [url] Start recording (defaults to current URL if omitted)
stop Stop recording and save video
restart <path> [url] Stop current recording (if any) and start a new one
Global Options:
--json Output as JSON
--session <name> 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 <path> [url] Start video recording (WebM)
record stop Stop and save video
console [--clear] View console logs
errors [--clear] View page errors
highlight <sel> Highlight element
+14
View File
@@ -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
+46
View File
@@ -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<Response<RecordingStartData>> {
await browser.startRecording(command.path, command.url);
return successResponse(command.id, {
started: true,
path: command.path,
});
}
async function handleRecordingStop(
command: RecordingStopCommand,
browser: BrowserManager
): Promise<Response<RecordingStopData>> {
const result = await browser.stopRecording();
return successResponse(command.id, result);
}
async function handleRecordingRestart(
command: RecordingRestartCommand,
browser: BrowserManager
): Promise<Response<RecordingRestartData>> {
const result = await browser.restartRecording(command.path, command.url);
return successResponse(command.id, {
started: true,
path: command.path,
previousPath: result.previousPath,
stopped: result.stopped,
});
}
+234
View File
@@ -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<void> {
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<void> {
// Stop recording if active (saves video)
if (this.recordingContext) {
await this.stopRecording();
}
// Stop screencast if active
if (this.screencastActive) {
await this.stopScreencast();
+20
View File
@@ -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,
+39 -1
View File
@@ -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;
}