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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user