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:
co-authored by
Claude Opus 4.5
Chris Tate
parent
3675e6bd7a
commit
1f31452fea
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user