Compare commits

...
Author SHA1 Message Date
Chris Tate 2c0c5a2acc v0.6.0 changelog 2026-01-18 11:44:29 -06:00
Chris Tate 818d9fa95e format code (#152) 2026-01-18 11:21:28 -06:00
Kye Burchard a8dcbb1222 feat: add connect command for persistent CDP sessions (#127)
Adds a `connect <port>` command that establishes a CDP connection
to a running browser. The daemon remembers the connection, so
subsequent commands work without needing --cdp on every call.

Example:
  agent-browser connect 9222
  agent-browser snapshot  # works without --cdp
  agent-browser tab
  agent-browser close
2026-01-18 10:54:12 -06:00
Mikhail Beliakovvercel[bot] <35613825+vercel[bot]@users.noreply.github.com>google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
a9fcef4579 fix: support libasound2t64 on newer Ubuntu versions (#112)
* fix: support libasound2t64 on newer Ubuntu versions

Updates the install logic to check if `libasound2t64` is available using
`apt-cache` before falling back to `libasound2`. This fixes installation
on Ubuntu 24.04 and other systems affected by the 64-bit time_t transition.

* Update cli/src/install.rs

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
2026-01-18 10:50:57 -06:00
Zhiwei Li 59baf97e51 fix: allow additional URL schemes in parse_command function (#125)
* fix: allow additional URL schemes in parse_command function

* fix: enhance URL validation in parse_command function to support lowercase schemes
2026-01-18 10:36:38 -06:00
0okay d02ef66c89 Refactor connection logic for Windows and hash calculationfix(cli): fix windows daemon startup and port calculation inconsistency (#79) 2026-01-18 09:14:03 -06:00
Danila Poyarkov 1689cf9eca fix(cli): handle SIGPIPE to prevent panic when piping output (#144) 2026-01-18 08:57:02 -06:00
Matthew KingandClaude Opus 4.5 03a53c9f36 feat: add Claude marketplace plugin (#106)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 07:49:27 -06:00
Zhiwei Li b1c0c6a366 feat: enhance response output with network request details and cleared status (#117) 2026-01-18 07:35:25 -06:00
Ryan DaigleandClaude Opus 4.5 c88734da89 feat: add NO_COLOR environment variable support (#122)
Add a centralized color module (cli/src/color.rs) that respects the
NO_COLOR environment variable per https://no-color.org/

Changes:
- Add color.rs module with helper functions for colored output
- Refactor all hardcoded ANSI escape codes to use the color module
- Add tests for color formatting functions
- Update AGENTS.md with color module usage guidelines

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 20:37:00 -06:00
Nicenonecb 28740acecf Fix CLI/protocol mismatches for select, frame main, and headers (#45)
* fix: align CLI command payloads with protocol

* fix(cli): support multi-value select in CLI
2026-01-17 20:25:19 -06:00
jaydenfyi 4112234371 fix(cli): Output screenshot as base64 string when no path provided (#83)
* fix(cli): print screenshot base64 when no path

* chore(docs): update docs and SKILL.md

* add test for screenshot with path arg

* more minimal readme + skill change
2026-01-17 20:18:06 -06:00
Li Yang 5e08e5d077 fix: detect stale unix socket by attempting connection (#114) 2026-01-17 19:42:48 -06:00
Sanchay 42879c337a fix: respect AGENT_BROWSER_HEADED env var for headed mode (#92)
The headless option was hardcoded to true in the auto-launch section,
ignoring the AGENT_BROWSER_HEADED environment variable. This fix checks
the env var so users can run the browser in headed mode by setting
AGENT_BROWSER_HEADED=1.

Fixes #90
2026-01-17 19:22:59 -06:00
Leon Gao 412ac63b68 fix: resolve refs in input value (#139) 2026-01-17 19:05:06 -06:00
Danila Poyarkov e6e832d2bc feat: add 'get styles' command for computed styles extraction (#142) 2026-01-17 18:56:52 -06:00
Dharma b19ca760aa fix: support URL parameter in tab new command (#64)
* fix: support URL parameter in tab new command

The CLI was correctly sending the URL parameter when running
`agent-browser tab new <url>`, but the TypeScript daemon was
ignoring it because:

1. The schema didn't include the url field (stripped during validation)
2. The TabNewCommand type didn't have a url property
3. The handler didn't pass the URL to browser.newTab()
4. browser.newTab() didn't accept or use a URL parameter

This fix adds URL support throughout the chain so that
`agent-browser tab new https://example.com` now correctly
opens a new tab and navigates to the specified URL.

Fixes #62

* fix: omit url field when not provided in tab new command

Previously, the CLI always sent "url": null when no URL was provided,
which caused Zod validation to fail with "Expected string, received null".

Now the url field is only included when a URL is actually provided.

Fixes issue reported by @ctate in PR review.

* refactor: move navigation logic from BrowserManager to handleTabNew

Address review feedback:
- Add .min(1) to URL validation for consistency with navigateSchema
- Keep BrowserManager.newTab() simple (single responsibility)
- Handle navigation in handleTabNew following same pattern as handleNavigate
2026-01-17 18:53:24 -06:00
Sheingandgoogle-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> e7c4936bc7 fix(cli): allow null path in screenshot command validation (#101)
The Rust CLI sends `null` for the `path` argument when it is not provided,
but the Zod schema only accepted `undefined`. This change updates the
`screenshotSchema` to allow `null` values for `path`, enabling the
`screenshot` command to work without a file path argument (outputting to stdout).

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-01-17 18:41:55 -06:00
Sheinggoogle-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>sheing-google
7aad47d3bd fix: Prevent CDP timeout on empty URL tabs (#102)
When connecting to a browser via CDP, particularly on Android, tabs with an empty URL can cause Playwright commands to hang indefinitely. This leads to a timeout in agent-browser.

This commit fixes the issue by filtering out any pages that have an empty `page.url()` during the CDP connection process. This prevents agent-browser from attempting to interact with these problematic tabs, resolving the timeout while preserving normal pages.

Added a unit test to verify that pages with empty URLs are correctly ignored. Also increased the timeout for a flaky screencast test to improve test suite stability.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: sheing-google <231310897+sheing-google@users.noreply.github.com>
2026-01-17 18:16:37 -06:00
edx.eth e196ed3e35 fix(cli): align protocol action names for wheel, emulatemedia, and find locators (#143)
- mouse wheel: send 'wheel' instead of 'mousewheel'
- set media: send 'emulatemedia' instead of 'media', fix reducedMotion to be string enum
- find locators: omit 'value' field when not provided (Zod .optional() expects undefined, not null)
  - Consistently applied to: role, label, placeholder, testid, first, last, nth

Fixes #131
2026-01-17 18:11:11 -06:00
1f31452fea 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>
2026-01-16 12:27:58 -06:00
NMW 3675e6bd7a feat: add --proxy flag for browser proxy configuration (#16)
* feat: add --proxy flag for browser proxy support

Add CLI flag to configure HTTP/SOCKS proxy for Playwright browser context.
Supports URL format with optional credentials: http://user:pass@host:port

* fix: improve proxy parsing error handling

- Handle malformed credentials (@ without :) by ignoring incomplete creds
- Replace unwrap() with expect() for better error messages
- Addresses Vercel bot code review suggestions

* Restaura cambios locales: soporte AGENT_BROWSER_HOME y timeout aumentado

- Agrega soporte para variable de entorno AGENT_BROWSER_HOME en connection.rs
- Aumenta timeout por defecto de 10s a 60s para conexiones más lentas

* feat: add --proxy flag for browser proxy configuration

Implements proxy support based on PR #16 with reviewer feedback:

Features:
- Parse proxy URLs: http://[user:pass@]host:port
- Support for HTTP, HTTPS, and SOCKS5 protocols
- Handle username-only auth (preserves username with empty password)
- Apply proxy to both standard and persistent contexts

Changes:
- cli/src/flags.rs: Add proxy flag parsing
- cli/src/main.rs: Add parse_proxy() with comprehensive tests
- cli/src/output.rs: Add --proxy to help output
- cli/src/commands.rs: Fix test helper to include proxy field
- src/types.ts: Add proxy to LaunchCommand interface
- src/protocol.ts: Add proxy validation schema
- src/browser.ts: Apply proxy to context creation

Tests:
- 7 unit tests for parse_proxy() covering all edge cases
- All Rust tests passing (69 tests)
- All TypeScript tests passing (168 tests)
- TypeScript typecheck passing

Resolves feedback from PR #16:
- Fixed username-only proxy handling (issue #2681046975)
- Added comprehensive unit tests
- Added --proxy to help documentation
- Used expect() instead of unwrap() for better error messages

* refactor: simplify parse_proxy function

- Remove redundant comments
- Extract server variable to reduce duplication
- Inline trivial username/password variables

All 7 proxy tests still passing.
2026-01-16 11:56:35 -06:00
Andrew GadzikandClaude Opus 4.5 fff9a146bd docs: update agent-browser skill with comprehensive command reference (#121)
Add documentation for new commands including focus, drag/drop, upload,
keydown/keyup, mouse control, cookies/storage, network interception,
tabs/windows, frames, dialogs, and browser settings.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:40:57 -06:00
edx.eth 34dcb7195a fix(cli): align console output field name with daemon response (#133)
The CLI expected a 'logs' field but the daemon returns 'messages'.
This caused 'agent-browser console' to display nothing.

Changed cli/src/output.rs to read 'messages' instead of 'logs',
matching the actual response from handleConsole in src/actions.ts.
2026-01-16 11:38:29 -06:00
Matthew KingandClaude Opus 4.5 7bdfcf8541 feat: add --version flag to CLI (#94)
Print the current version when `agent-browser --version` is passed.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:35:06 -06:00
Chris Tate 6abee37641 v0.5.0 (#78) 2026-01-13 21:54:20 -06:00
25 changed files with 1626 additions and 128 deletions
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "agent-browser",
"description": "Headless browser automation for AI agents",
"owner": {
"name": "Vercel",
"email": "support@vercel.com"
},
"plugins": [
{
"name": "agent-browser",
"description": "Automates browser interactions for web testing, form filling, screenshots, and data extraction",
"source": "./",
"strict": false,
"skills": ["./skills/agent-browser"],
"category": "development"
}
]
}
+1
View File
@@ -5,6 +5,7 @@ Instructions for AI coding agents working with this codebase.
## Code Style
- Do not use emojis in code, output, or documentation. Unicode symbols (✓, ✗, →, ⚠) are acceptable.
- CLI colored output uses `cli/src/color.rs`. This module respects the `NO_COLOR` environment variable. Never use hardcoded ANSI color codes.
<!-- opensrc:start -->
+11 -6
View File
@@ -74,10 +74,11 @@ agent-browser scroll <dir> [px] # Scroll (up/down/left/right)
agent-browser scrollintoview <sel> # Scroll element into view (alias: scrollinto)
agent-browser drag <src> <tgt> # Drag and drop
agent-browser upload <sel> <files> # Upload files
agent-browser screenshot [path] # Take screenshot (--full for full page)
agent-browser screenshot [path] # Take screenshot (--full for full page, base64 png to stdout if no path)
agent-browser pdf <path> # Save as PDF
agent-browser snapshot # Accessibility tree with refs (best for AI)
agent-browser eval <js> # Run JavaScript
agent-browser connect <port> # Connect to browser via CDP
agent-browser close # Close browser (aliases: quit, exit)
```
@@ -465,12 +466,16 @@ export async function handler() {
Connect to an existing browser via Chrome DevTools Protocol:
```bash
# Connect to Electron app
agent-browser --cdp 9222 snapshot
# Start Chrome with: google-chrome --remote-debugging-port=9222
# Connect to Chrome with remote debugging
# (Start Chrome with: google-chrome --remote-debugging-port=9222)
agent-browser --cdp 9222 open about:blank
# Connect once, then run commands without --cdp
agent-browser connect 9222
agent-browser snapshot
agent-browser tab
agent-browser close
# Or pass --cdp on each command
agent-browser --cdp 9222 snapshot
```
This enables control of:
+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "agent-browser"
version = "0.4.4"
version = "0.5.0"
dependencies = [
"libc",
"serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-browser"
version = "0.4.4"
version = "0.5.0"
edition = "2021"
description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0"
+158
View File
@@ -0,0 +1,158 @@
//! Color output utilities respecting NO_COLOR environment variable.
//!
//! When the NO_COLOR environment variable is present (regardless of value),
//! all color formatting is disabled per https://no-color.org/
use std::env;
use std::sync::OnceLock;
/// Returns true if color output is enabled (NO_COLOR is NOT set)
pub fn is_enabled() -> bool {
static COLORS_ENABLED: OnceLock<bool> = OnceLock::new();
*COLORS_ENABLED.get_or_init(|| env::var("NO_COLOR").is_err())
}
/// Format text in red (errors)
pub fn red(text: &str) -> String {
if is_enabled() {
format!("\x1b[31m{}\x1b[0m", text)
} else {
text.to_string()
}
}
/// Format text in green (success)
pub fn green(text: &str) -> String {
if is_enabled() {
format!("\x1b[32m{}\x1b[0m", text)
} else {
text.to_string()
}
}
/// Format text in yellow (warnings)
pub fn yellow(text: &str) -> String {
if is_enabled() {
format!("\x1b[33m{}\x1b[0m", text)
} else {
text.to_string()
}
}
/// Format text in cyan (info/progress)
pub fn cyan(text: &str) -> String {
if is_enabled() {
format!("\x1b[36m{}\x1b[0m", text)
} else {
text.to_string()
}
}
/// Format text in bold
pub fn bold(text: &str) -> String {
if is_enabled() {
format!("\x1b[1m{}\x1b[0m", text)
} else {
text.to_string()
}
}
/// Format text in dim
pub fn dim(text: &str) -> String {
if is_enabled() {
format!("\x1b[2m{}\x1b[0m", text)
} else {
text.to_string()
}
}
/// Red X error indicator
pub fn error_indicator() -> &'static str {
static INDICATOR: OnceLock<String> = OnceLock::new();
INDICATOR.get_or_init(|| {
if is_enabled() {
"\x1b[31m✗\x1b[0m".to_string()
} else {
"".to_string()
}
})
}
/// Green checkmark success indicator
pub fn success_indicator() -> &'static str {
static INDICATOR: OnceLock<String> = OnceLock::new();
INDICATOR.get_or_init(|| {
if is_enabled() {
"\x1b[32m✓\x1b[0m".to_string()
} else {
"".to_string()
}
})
}
/// Yellow warning indicator
pub fn warning_indicator() -> &'static str {
static INDICATOR: OnceLock<String> = OnceLock::new();
INDICATOR.get_or_init(|| {
if is_enabled() {
"\x1b[33m⚠\x1b[0m".to_string()
} else {
"".to_string()
}
})
}
/// Get console log color prefix by level
pub fn console_level_prefix(level: &str) -> String {
if !is_enabled() {
return format!("[{}]", level);
}
let color = match level {
"error" => "\x1b[31m",
"warning" => "\x1b[33m",
"info" => "\x1b[36m",
_ => "",
};
if color.is_empty() {
format!("[{}]", level)
} else {
format!("{}[{}]\x1b[0m", color, level)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_red_contains_ansi_codes() {
// Test the format structure (actual color depends on NO_COLOR env)
let formatted = format!("\x1b[31m{}\x1b[0m", "error");
assert!(formatted.contains("\x1b[31m"));
assert!(formatted.contains("\x1b[0m"));
}
#[test]
fn test_green_contains_ansi_codes() {
let formatted = format!("\x1b[32m{}\x1b[0m", "success");
assert!(formatted.contains("\x1b[32m"));
}
#[test]
fn test_console_level_prefix_contains_level() {
// Regardless of color state, the level text should be present
assert!(console_level_prefix("error").contains("error"));
assert!(console_level_prefix("warning").contains("warning"));
assert!(console_level_prefix("info").contains("info"));
assert!(console_level_prefix("log").contains("log"));
}
#[test]
fn test_indicators_contain_symbols() {
// Regardless of color state, symbols should be present
assert!(error_indicator().contains('✗'));
assert!(success_indicator().contains('✓'));
assert!(warning_indicator().contains('⚠'));
}
}
+343 -26
View File
@@ -75,7 +75,12 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
context: cmd.to_string(),
usage: "open <url>",
})?;
let url = if url.starts_with("http") {
let url_lower = url.to_lowercase();
let url = if url_lower.starts_with("http://")
|| url_lower.starts_with("https://")
|| url_lower.starts_with("about:")
|| url_lower.starts_with("data:")
|| url_lower.starts_with("file:") {
url.to_string()
} else {
format!("https://{}", url)
@@ -153,13 +158,18 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
"select" => {
let sel = rest.get(0).ok_or_else(|| ParseError::MissingArguments {
context: "select".to_string(),
usage: "select <selector> <value>",
usage: "select <selector> <value...>",
})?;
let val = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
let _val = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "select".to_string(),
usage: "select <selector> <value>",
usage: "select <selector> <value...>",
})?;
Ok(json!({ "id": id, "action": "select", "selector": sel, "value": val }))
let values = &rest[1..];
if values.len() == 1 {
Ok(json!({ "id": id, "action": "select", "selector": sel, "values": values[0] }))
} else {
Ok(json!({ "id": id, "action": "select", "selector": sel, "values": values }))
}
}
"drag" => {
let src = rest.get(0).ok_or_else(|| ParseError::MissingArguments {
@@ -273,7 +283,11 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === Screenshot/PDF ===
"screenshot" => {
Ok(json!({ "id": id, "action": "screenshot", "path": rest.get(0), "fullPage": flags.full }))
let mut cmd = json!({ "id": id, "action": "screenshot", "fullPage": flags.full });
if let Some(path) = rest.get(0) {
cmd["path"] = json!(path);
}
Ok(cmd)
}
"pdf" => {
let path = rest.get(0).ok_or_else(|| ParseError::MissingArguments {
@@ -323,6 +337,19 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === Close ===
"close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "close" })),
// === Connect (CDP) ===
"connect" => {
let port_str = rest.get(0).ok_or_else(|| ParseError::MissingArguments {
context: "connect".to_string(),
usage: "connect <port>",
})?;
let port: u16 = port_str.parse().map_err(|_| ParseError::MissingArguments {
context: format!("connect: invalid port '{}'", port_str),
usage: "connect <port>",
})?;
Ok(json!({ "id": id, "action": "launch", "cdpPort": port }))
}
// === Get ===
"get" => parse_get(&rest, &id),
@@ -367,10 +394,20 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === Tabs ===
"tab" => {
match rest.get(0).map(|s| *s) {
Some("new") => Ok(json!({ "id": id, "action": "tab_new", "url": rest.get(1) })),
Some("new") => {
let mut cmd = json!({ "id": id, "action": "tab_new" });
if let Some(url) = rest.get(1) {
cmd["url"] = json!(url);
}
Ok(cmd)
}
Some("list") => Ok(json!({ "id": id, "action": "tab_list" })),
Some("close") => {
Ok(json!({ "id": id, "action": "tab_close", "index": rest.get(1).and_then(|s| s.parse::<i32>().ok()) }))
let mut cmd = json!({ "id": id, "action": "tab_close" });
if let Some(index) = rest.get(1).and_then(|s| s.parse::<i32>().ok()) {
cmd["index"] = json!(index);
}
Ok(cmd)
}
Some(n) if n.parse::<i32>().is_ok() => {
Ok(json!({ "id": id, "action": "tab_switch", "index": n.parse::<i32>().unwrap() }))
@@ -398,7 +435,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === Frame ===
"frame" => {
if rest.get(0).map(|s| *s) == Some("main") {
Ok(json!({ "id": id, "action": "frame_main" }))
Ok(json!({ "id": id, "action": "mainframe" }))
} else {
let sel = rest.get(0).ok_or_else(|| ParseError::MissingArguments {
context: "frame".to_string(),
@@ -413,7 +450,11 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
const VALID: &[&str] = &["accept", "dismiss"];
match rest.get(0).map(|s| *s) {
Some("accept") => {
Ok(json!({ "id": id, "action": "dialog", "response": "accept", "promptText": rest.get(1) }))
let mut cmd = json!({ "id": id, "action": "dialog", "response": "accept" });
if let Some(prompt_text) = rest.get(1) {
cmd["promptText"] = json!(prompt_text);
}
Ok(cmd)
}
Some("dismiss") => Ok(json!({ "id": id, "action": "dialog", "response": "dismiss" })),
Some(sub) => Err(ParseError::UnknownSubcommand {
@@ -431,8 +472,14 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
"trace" => {
const VALID: &[&str] = &["start", "stop"];
match rest.get(0).map(|s| *s) {
Some("start") => Ok(json!({ "id": id, "action": "trace_start", "path": rest.get(1) })),
Some("stop") => Ok(json!({ "id": id, "action": "trace_stop", "path": rest.get(1) })),
Some("start") => Ok(json!({ "id": id, "action": "trace_start" })),
Some("stop") => {
let path = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "trace stop".to_string(),
usage: "trace stop <path>",
})?;
Ok(json!({ "id": id, "action": "trace_stop", "path": path }))
},
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
valid_options: VALID,
@@ -443,6 +490,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 }))
@@ -495,7 +596,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}
fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
const VALID: &[&str] = &["text", "html", "value", "attr", "url", "title", "count", "box"];
const VALID: &[&str] = &["text", "html", "value", "attr", "url", "title", "count", "box", "styles"];
match rest.get(0).map(|s| *s) {
Some("text") => {
@@ -546,13 +647,20 @@ fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
})?;
Ok(json!({ "id": id, "action": "boundingbox", "selector": sel }))
}
Some("styles") => {
let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "get styles".to_string(),
usage: "get styles <selector>",
})?;
Ok(json!({ "id": id, "action": "styles", "selector": sel }))
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
valid_options: VALID,
}),
None => Err(ParseError::MissingArguments {
context: "get".to_string(),
usage: "get <text|html|value|attr|url|title|count|box> [args...]",
usage: "get <text|html|value|attr|url|title|count|box|styles> [args...]",
}),
}
}
@@ -630,15 +738,39 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
};
match *locator {
"role" => Ok(json!({ "id": id, "action": "getbyrole", "role": value, "subaction": subaction, "value": fill_value, "name": name, "exact": exact })),
"role" => {
let mut cmd = json!({ "id": id, "action": "getbyrole", "role": value, "subaction": subaction, "name": name, "exact": exact });
if let Some(v) = fill_value { cmd["value"] = json!(v); }
Ok(cmd)
}
"text" => Ok(json!({ "id": id, "action": "getbytext", "text": value, "subaction": subaction, "exact": exact })),
"label" => Ok(json!({ "id": id, "action": "getbylabel", "label": value, "subaction": subaction, "value": fill_value, "exact": exact })),
"placeholder" => Ok(json!({ "id": id, "action": "getbyplaceholder", "placeholder": value, "subaction": subaction, "value": fill_value, "exact": exact })),
"label" => {
let mut cmd = json!({ "id": id, "action": "getbylabel", "label": value, "subaction": subaction, "exact": exact });
if let Some(v) = fill_value { cmd["value"] = json!(v); }
Ok(cmd)
}
"placeholder" => {
let mut cmd = json!({ "id": id, "action": "getbyplaceholder", "placeholder": value, "subaction": subaction, "exact": exact });
if let Some(v) = fill_value { cmd["value"] = json!(v); }
Ok(cmd)
}
"alt" => Ok(json!({ "id": id, "action": "getbyalttext", "text": value, "subaction": subaction, "exact": exact })),
"title" => Ok(json!({ "id": id, "action": "getbytitle", "text": value, "subaction": subaction, "exact": exact })),
"testid" => Ok(json!({ "id": id, "action": "getbytestid", "testId": value, "subaction": subaction, "value": fill_value })),
"first" => Ok(json!({ "id": id, "action": "nth", "selector": value, "index": 0, "subaction": subaction, "value": fill_value })),
"last" => Ok(json!({ "id": id, "action": "nth", "selector": value, "index": -1, "subaction": subaction, "value": fill_value })),
"testid" => {
let mut cmd = json!({ "id": id, "action": "getbytestid", "testId": value, "subaction": subaction });
if let Some(v) = fill_value { cmd["value"] = json!(v); }
Ok(cmd)
}
"first" => {
let mut cmd = json!({ "id": id, "action": "nth", "selector": value, "index": 0, "subaction": subaction });
if let Some(v) = fill_value { cmd["value"] = json!(v); }
Ok(cmd)
}
"last" => {
let mut cmd = json!({ "id": id, "action": "nth", "selector": value, "index": -1, "subaction": subaction });
if let Some(v) = fill_value { cmd["value"] = json!(v); }
Ok(cmd)
}
_ => unreachable!(),
}
}
@@ -661,7 +793,9 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
} else {
None
};
Ok(json!({ "id": id, "action": "nth", "selector": sel, "index": idx, "subaction": sub, "value": fv }))
let mut cmd = json!({ "id": id, "action": "nth", "selector": sel, "index": idx, "subaction": sub });
if let Some(v) = fv { cmd["value"] = json!(v); }
Ok(cmd)
}
_ => Err(ParseError::UnknownSubcommand {
subcommand: locator.to_string(),
@@ -702,7 +836,7 @@ fn parse_mouse(rest: &[&str], id: &str) -> Result<Value, ParseError> {
Some("wheel") => {
let dy = rest.get(1).and_then(|s| s.parse::<i32>().ok()).unwrap_or(100);
let dx = rest.get(2).and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
Ok(json!({ "id": id, "action": "mousewheel", "deltaX": dx, "deltaY": dy }))
Ok(json!({ "id": id, "action": "wheel", "deltaX": dx, "deltaY": dy }))
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
@@ -800,8 +934,12 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
} else {
"no-preference"
};
let reduced = rest.iter().any(|&s| s == "reduced-motion");
Ok(json!({ "id": id, "action": "media", "colorScheme": color, "reducedMotion": reduced }))
let reduced = if rest.iter().any(|&s| s == "reduced-motion") {
"reduce"
} else {
"no-preference"
};
Ok(json!({ "id": id, "action": "emulatemedia", "colorScheme": color, "reducedMotion": reduced }))
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
@@ -828,12 +966,22 @@ fn parse_network(rest: &[&str], id: &str) -> Result<Value, ParseError> {
let body = body_idx.and_then(|i| rest.get(i + 1).map(|s| *s));
Ok(json!({ "id": id, "action": "route", "url": url, "abort": abort, "body": body }))
}
Some("unroute") => Ok(json!({ "id": id, "action": "unroute", "url": rest.get(1) })),
Some("unroute") => {
let mut cmd = json!({ "id": id, "action": "unroute" });
if let Some(url) = rest.get(1) {
cmd["url"] = json!(url);
}
Ok(cmd)
},
Some("requests") => {
let clear = rest.iter().any(|&s| s == "--clear");
let filter_idx = rest.iter().position(|&s| s == "--filter");
let filter = filter_idx.and_then(|i| rest.get(i + 1).map(|s| *s));
Ok(json!({ "id": id, "action": "requests", "clear": clear, "filter": filter }))
let mut cmd = json!({ "id": id, "action": "requests", "clear": clear });
if let Some(f) = filter {
cmd["filter"] = json!(f);
}
Ok(cmd)
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
@@ -903,6 +1051,7 @@ mod tests {
executable_path: None,
extensions: Vec::new(),
cdp: None,
proxy: None,
}
}
@@ -1147,12 +1296,46 @@ mod tests {
assert_eq!(cmd["text"], "some text");
}
#[test]
fn test_select() {
let cmd = parse_command(&args("select #menu option1"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "select");
assert_eq!(cmd["selector"], "#menu");
assert_eq!(cmd["values"], "option1");
}
#[test]
fn test_select_multiple_values() {
let cmd = parse_command(
&args("select #menu opt1 opt2 opt3"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "select");
assert_eq!(cmd["selector"], "#menu");
assert_eq!(cmd["values"], json!(["opt1", "opt2", "opt3"]));
}
#[test]
fn test_frame_main() {
let cmd = parse_command(&args("frame main"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "mainframe");
}
// === Tabs ===
#[test]
fn test_tab_new() {
let cmd = parse_command(&args("tab new"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "tab_new");
assert!(cmd.get("url").is_none(), "url should not be present when not provided");
}
#[test]
fn test_tab_new_with_url() {
let cmd = parse_command(&args("tab new https://example.com"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "tab_new");
assert_eq!(cmd["url"], "https://example.com");
}
#[test]
@@ -1180,6 +1363,14 @@ mod tests {
fn test_screenshot() {
let cmd = parse_command(&args("screenshot"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "screenshot");
assert!(cmd.get("path").is_none());
}
#[test]
fn test_screenshot_path() {
let cmd = parse_command(&args("screenshot out.png"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "screenshot");
assert_eq!(cmd["path"], "out.png");
}
#[test]
@@ -1273,6 +1464,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());
@@ -1316,4 +1583,54 @@ mod tests {
assert!(matches!(err, ParseError::MissingArguments { .. }));
assert!(err.format().contains("get text"));
}
// === Protocol alignment tests ===
#[test]
fn test_mouse_wheel() {
let cmd = parse_command(&args("mouse wheel 100 50"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "wheel");
assert_eq!(cmd["deltaY"], 100);
assert_eq!(cmd["deltaX"], 50);
}
#[test]
fn test_set_media() {
let cmd = parse_command(&args("set media dark"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "emulatemedia");
assert_eq!(cmd["colorScheme"], "dark");
assert_eq!(cmd["reducedMotion"], "no-preference");
}
#[test]
fn test_set_media_reduced_motion() {
let cmd = parse_command(&args("set media light reduced-motion"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "emulatemedia");
assert_eq!(cmd["colorScheme"], "light");
assert_eq!(cmd["reducedMotion"], "reduce");
}
#[test]
fn test_find_first_no_value() {
let cmd = parse_command(&args("find first a click"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "nth");
assert_eq!(cmd["index"], 0);
assert!(cmd.get("value").is_none());
}
#[test]
fn test_find_first_with_value() {
let cmd = parse_command(&args("find first input fill hello"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "nth");
assert_eq!(cmd["index"], 0);
assert_eq!(cmd["value"], "hello");
}
#[test]
fn test_find_nth_no_value() {
let cmd = parse_command(&args("find nth 2 a click"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "nth");
assert_eq!(cmd["index"], 2);
assert!(cmd.get("value").is_none());
}
}
+18 -11
View File
@@ -104,7 +104,9 @@ fn get_port_for_session(session: &str) -> u16 {
for c in session.chars() {
hash = ((hash << 5).wrapping_sub(hash)).wrapping_add(c as i32);
}
49152 + ((hash.abs() as u16) % 16383)
// Correct logic: first take absolute modulo, then cast to u16
// Using unsigned_abs() to safely handle i32::MIN
49152 + ((hash.unsigned_abs() as u32 % 16383) as u16)
}
#[cfg(unix)]
@@ -140,7 +142,8 @@ fn is_daemon_running(session: &str) -> bool {
fn daemon_ready(session: &str) -> bool {
#[cfg(unix)]
{
get_socket_path(session).exists()
let socket_path = get_socket_path(session);
UnixStream::connect(&socket_path).is_ok()
}
#[cfg(windows)]
{
@@ -174,16 +177,23 @@ pub fn ensure_daemon(
let exe_path = env::current_exe().map_err(|e| e.to_string())?;
let exe_dir = exe_path.parent().unwrap();
let daemon_paths = [
let mut daemon_paths = vec![
exe_dir.join("daemon.js"),
exe_dir.join("../dist/daemon.js"),
PathBuf::from("dist/daemon.js"),
];
// Check AGENT_BROWSER_HOME environment variable
if let Ok(home) = env::var("AGENT_BROWSER_HOME") {
let home_path = PathBuf::from(&home);
daemon_paths.insert(0, home_path.join("dist/daemon.js"));
daemon_paths.insert(1, home_path.join("daemon.js"));
}
let daemon_path = daemon_paths
.iter()
.find(|p| p.exists())
.ok_or("Daemon not found. Run from project directory or ensure daemon.js is alongside binary.")?;
.ok_or("Daemon not found. Set AGENT_BROWSER_HOME environment variable or run from project directory.")?;
// Spawn daemon as a fully detached background process
#[cfg(unix)]
@@ -227,13 +237,10 @@ pub fn ensure_daemon(
{
use std::os::windows::process::CommandExt;
// On Windows, use cmd.exe to run node to ensure proper PATH resolution.
// This handles cases where node.exe isn't directly in PATH but node.cmd is.
// Pass the entire command as a single string to /c to handle paths with spaces.
let cmd_string = format!("node \"{}\"", daemon_path.display());
let mut cmd = Command::new("cmd");
cmd.arg("/c")
.arg(&cmd_string)
// On Windows, call node directly. Command::new handles PATH resolution (node.exe or node.cmd)
// and automatically quotes arguments containing spaces.
let mut cmd = Command::new("node");
cmd.arg(daemon_path)
.env("AGENT_BROWSER_DAEMON", "1")
.env("AGENT_BROWSER_SESSION", session);
+9 -1
View File
@@ -10,6 +10,7 @@ pub struct Flags {
pub executable_path: Option<String>,
pub cdp: Option<String>,
pub extensions: Vec<String>,
pub proxy: Option<String>,
}
pub fn parse_flags(args: &[String]) -> Flags {
@@ -28,6 +29,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(),
cdp: None,
extensions: extensions_env,
proxy: None,
};
let mut i = 0;
@@ -67,6 +69,12 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--proxy" => {
if let Some(p) = args.get(i + 1) {
flags.proxy = Some(p.clone());
i += 1;
}
}
_ => {}
}
i += 1;
@@ -81,7 +89,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
// Global flags that should be stripped from command args
const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"];
// Global flags that take a value (need to skip the next arg too)
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers", "--executable-path", "--cdp", "--extension"];
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers", "--executable-path", "--cdp", "--extension", "--proxy"];
for arg in args.iter() {
if skip_next {
+32 -13
View File
@@ -1,3 +1,4 @@
use crate::color;
use std::process::{exit, Command, Stdio};
pub fn run_install(with_deps: bool) {
@@ -5,9 +6,15 @@ pub fn run_install(with_deps: bool) {
if is_linux {
if with_deps {
println!("\x1b[36mInstalling system dependencies...\x1b[0m");
println!("{}", color::cyan("Installing system dependencies..."));
let (pkg_mgr, deps) = if which_exists("apt-get") {
let libasound = if package_exists_apt("libasound2t64") {
"libasound2t64"
} else {
"libasound2"
};
(
"apt-get",
vec![
@@ -30,7 +37,7 @@ pub fn run_install(with_deps: bool) {
"libcairo2",
"libgdk-pixbuf-2.0-0",
"libxrender1",
"libasound2",
libasound,
"libfreetype6",
"libfontconfig1",
"libdbus-1-3",
@@ -93,7 +100,7 @@ pub fn run_install(with_deps: bool) {
],
)
} else {
eprintln!("\x1b[31m✗\x1b[0m No supported package manager found (apt-get, dnf, or yum)");
eprintln!("{} No supported package manager found (apt-get, dnf, or yum)", color::error_indicator());
exit(1);
};
@@ -112,22 +119,23 @@ pub fn run_install(with_deps: bool) {
match status {
Ok(s) if s.success() => {
println!("\x1b[32m✓\x1b[0m System dependencies installed")
println!("{} System dependencies installed", color::success_indicator())
}
Ok(_) => eprintln!(
"\x1b[33m⚠\x1b[0m Failed to install some dependencies. You may need to run manually with sudo."
"{} Failed to install some dependencies. You may need to run manually with sudo.",
color::warning_indicator()
),
Err(e) => eprintln!("\x1b[33m⚠\x1b[0m Could not run install command: {}", e),
Err(e) => eprintln!("{} Could not run install command: {}", color::warning_indicator(), e),
}
} else {
println!("\x1b[33m⚠\x1b[0m Linux detected. If browser fails to launch, run:");
println!("{} Linux detected. If browser fails to launch, run:", color::warning_indicator());
println!(" agent-browser install --with-deps");
println!(" or: npx playwright install-deps chromium");
println!();
}
}
println!("\x1b[36mInstalling Chromium browser...\x1b[0m");
println!("{}", color::cyan("Installing Chromium browser..."));
// On Windows, we need to use cmd.exe to run npx because npx is actually npx.cmd
// and Command::new() doesn't resolve .cmd files the way the shell does.
@@ -144,23 +152,23 @@ pub fn run_install(with_deps: bool) {
match status {
Ok(s) if s.success() => {
println!("\x1b[32m✓\x1b[0m Chromium installed successfully");
println!("{} Chromium installed successfully", color::success_indicator());
if is_linux && !with_deps {
println!();
println!("\x1b[33mNote:\x1b[0m If you see \"shared library\" errors when running, use:");
println!("{} If you see \"shared library\" errors when running, use:", color::yellow("Note:"));
println!(" agent-browser install --with-deps");
}
}
Ok(_) => {
eprintln!("\x1b[31m✗\x1b[0m Failed to install browser");
eprintln!("{} Failed to install browser", color::error_indicator());
if is_linux {
println!("\x1b[33mTip:\x1b[0m Try installing system dependencies first:");
println!("{} Try installing system dependencies first:", color::yellow("Tip:"));
println!(" agent-browser install --with-deps");
}
exit(1);
}
Err(e) => {
eprintln!("\x1b[31m✗\x1b[0m Failed to run npx: {}", e);
eprintln!("{} Failed to run npx: {}", color::error_indicator(), e);
eprintln!("Make sure Node.js is installed and npx is in your PATH");
exit(1);
}
@@ -189,3 +197,14 @@ fn which_exists(cmd: &str) -> bool {
.unwrap_or(false)
}
}
fn package_exists_apt(pkg: &str) -> bool {
Command::new("apt-cache")
.arg("show")
.arg(pkg)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
+130 -21
View File
@@ -1,3 +1,4 @@
mod color;
mod commands;
mod connection;
mod flags;
@@ -21,7 +22,37 @@ use commands::{gen_id, parse_command, ParseError};
use connection::{ensure_daemon, send_command};
use flags::{clean_args, parse_flags};
use install::run_install;
use output::{print_command_help, print_help, print_response};
use output::{print_command_help, print_help, print_response, print_version};
fn parse_proxy(proxy_str: &str) -> serde_json::Value {
let Some(protocol_end) = proxy_str.find("://") else {
return json!({ "server": proxy_str });
};
let protocol = &proxy_str[..protocol_end + 3];
let rest = &proxy_str[protocol_end + 3..];
let Some(at_pos) = rest.rfind('@') else {
return json!({ "server": proxy_str });
};
let creds = &rest[..at_pos];
let server_part = &rest[at_pos + 1..];
let server = format!("{}{}", protocol, server_part);
let Some(colon_pos) = creds.find(':') else {
return json!({
"server": server,
"username": creds,
"password": ""
});
};
json!({
"server": server,
"username": &creds[..colon_pos],
"password": &creds[colon_pos + 1..]
})
}
fn run_session(args: &[String], session: &str, json_mode: bool) {
let subcommand = args.get(1).map(|s| s.as_str());
@@ -77,7 +108,7 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
} else {
println!("Active sessions:");
for s in &sessions {
let marker = if s == session { "" } else { " " };
let marker = if s == session { color::cyan("") } else { " ".to_string() };
println!("{} {}", marker, s);
}
}
@@ -94,16 +125,18 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
}
fn main() {
// Ignore SIGPIPE to prevent panic when piping to head/tail
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
let args: Vec<String> = env::args().skip(1).collect();
let flags = parse_flags(&args);
let clean = clean_args(&args);
let has_help = args.iter().any(|a| a == "--help" || a == "-h");
if clean.is_empty() {
print_help();
return;
}
let has_version = args.iter().any(|a| a == "--version" || a == "-V");
if has_help {
if let Some(cmd) = clean.get(0) {
@@ -115,6 +148,16 @@ fn main() {
return;
}
if has_version {
print_version();
return;
}
if clean.is_empty() {
print_help();
return;
}
// Handle install separately
if clean.get(0).map(|s| s.as_str()) == Some("install") {
let with_deps = args.iter().any(|a| a == "--with-deps" || a == "-d");
@@ -143,7 +186,7 @@ fn main() {
error_type
);
} else {
eprintln!("\x1b[31m{}\x1b[0m", e.format());
eprintln!("{}", color::red(&e.format()));
}
exit(1);
}
@@ -155,7 +198,7 @@ fn main() {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, e);
} else {
eprintln!("\x1b[31m✗\x1b[0m {}", e);
eprintln!("{} {}", color::error_indicator(), e);
}
exit(1);
}
@@ -165,10 +208,10 @@ fn main() {
if daemon_result.already_running && (flags.executable_path.is_some() || !flags.extensions.is_empty()) {
if !flags.json {
if flags.executable_path.is_some() {
eprintln!("\x1b[33m⚠\x1b[0m --executable-path ignored: daemon already running. Use 'agent-browser close' first to restart with new path.");
eprintln!("{} --executable-path ignored: daemon already running. Use 'agent-browser close' first to restart with new path.", color::warning_indicator());
}
if !flags.extensions.is_empty() {
eprintln!("\x1b[33m⚠\x1b[0m --extension ignored: daemon already running. Use 'agent-browser close' first to restart with extensions.");
eprintln!("{} --extension ignored: daemon already running. Use 'agent-browser close' first to restart with extensions.", color::warning_indicator());
}
}
}
@@ -181,7 +224,7 @@ fn main() {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("\x1b[31m✗\x1b[0m {}", msg);
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
@@ -190,7 +233,7 @@ fn main() {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("\x1b[31m✗\x1b[0m {}", msg);
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
@@ -200,7 +243,7 @@ fn main() {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("\x1b[31m✗\x1b[0m {}", msg);
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
@@ -222,23 +265,30 @@ fn main() {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("\x1b[31m✗\x1b[0m {}", msg);
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
}
// Launch headed browser if --headed flag is set (without CDP)
if flags.headed && flags.cdp.is_none() {
let launch_cmd = json!({
// Launch headed browser or proxy if flags are set (without CDP)
if (flags.headed || flags.proxy.is_some()) && flags.cdp.is_none() {
let mut launch_cmd = json!({
"id": gen_id(),
"action": "launch",
"headless": false
"headless": !flags.headed
});
if let Some(ref proxy_str) = flags.proxy {
let proxy_obj = parse_proxy(proxy_str);
launch_cmd.as_object_mut()
.expect("json! macro guarantees object type")
.insert("proxy".to_string(), proxy_obj);
}
if let Err(e) = send_command(launch_cmd, &flags.session) {
if !flags.json {
eprintln!("\x1b[33m⚠\x1b[0m Could not launch headed browser: {}", e);
eprintln!("{} Could not configure browser: {}", color::warning_indicator(), e);
}
}
}
@@ -255,9 +305,68 @@ fn main() {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, e);
} else {
eprintln!("\x1b[31m✗\x1b[0m {}", e);
eprintln!("{} {}", color::error_indicator(), e);
}
exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_proxy_simple() {
let result = parse_proxy("http://proxy.com:8080");
assert_eq!(result["server"], "http://proxy.com:8080");
assert!(result.get("username").is_none());
assert!(result.get("password").is_none());
}
#[test]
fn test_parse_proxy_with_auth() {
let result = parse_proxy("http://user:pass@proxy.com:8080");
assert_eq!(result["server"], "http://proxy.com:8080");
assert_eq!(result["username"], "user");
assert_eq!(result["password"], "pass");
}
#[test]
fn test_parse_proxy_username_only() {
let result = parse_proxy("http://user@proxy.com:8080");
assert_eq!(result["server"], "http://proxy.com:8080");
assert_eq!(result["username"], "user");
assert_eq!(result["password"], "");
}
#[test]
fn test_parse_proxy_no_protocol() {
let result = parse_proxy("proxy.com:8080");
assert_eq!(result["server"], "proxy.com:8080");
assert!(result.get("username").is_none());
}
#[test]
fn test_parse_proxy_socks5() {
let result = parse_proxy("socks5://proxy.com:1080");
assert_eq!(result["server"], "socks5://proxy.com:1080");
assert!(result.get("username").is_none());
}
#[test]
fn test_parse_proxy_socks5_with_auth() {
let result = parse_proxy("socks5://admin:secret@proxy.com:1080");
assert_eq!(result["server"], "socks5://proxy.com:1080");
assert_eq!(result["username"], "admin");
assert_eq!(result["password"], "secret");
}
#[test]
fn test_parse_proxy_complex_password() {
let result = parse_proxy("http://user:p@ss:w0rd@proxy.com:8080");
assert_eq!(result["server"], "http://proxy.com:8080");
assert_eq!(result["username"], "user");
assert_eq!(result["password"], "p@ss:w0rd");
}
}
+162 -23
View File
@@ -1,3 +1,4 @@
use crate::color;
use crate::connection::Response;
pub fn print_response(resp: &Response, json_mode: bool) {
@@ -8,7 +9,8 @@ pub fn print_response(resp: &Response, json_mode: bool) {
if !resp.success {
eprintln!(
"\x1b[31m✗\x1b[0m {}",
"{} {}",
color::error_indicator(),
resp.error.as_deref().unwrap_or("Unknown error")
);
return;
@@ -18,8 +20,8 @@ pub fn print_response(resp: &Response, json_mode: bool) {
// Navigation response
if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
println!("\x1b[32m✓\x1b[0m \x1b[1m{}\x1b[0m", title);
println!("\x1b[2m {}\x1b[0m", url);
println!("{} {}", color::success_indicator(), color::bold(title));
println!(" {}", color::dim(url));
return;
}
println!("{}", url);
@@ -85,23 +87,17 @@ pub fn print_response(resp: &Response, json_mode: bool) {
.unwrap_or("Untitled");
let url = tab.get("url").and_then(|v| v.as_str()).unwrap_or("");
let active = tab.get("active").and_then(|v| v.as_bool()).unwrap_or(false);
let marker = if active { "" } else { " " };
let marker = if active { color::cyan("") } else { " ".to_string() };
println!("{} [{}] {} - {}", marker, i, title, url);
}
return;
}
// Console logs
if let Some(logs) = data.get("logs").and_then(|v| v.as_array()) {
if let Some(logs) = data.get("messages").and_then(|v| v.as_array()) {
for log in logs {
let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log");
let text = log.get("text").and_then(|v| v.as_str()).unwrap_or("");
let color = match level {
"error" => "\x1b[31m",
"warning" => "\x1b[33m",
"info" => "\x1b[36m",
_ => "\x1b[0m",
};
println!("{}[{}]\x1b[0m {}", color, level, text);
println!("{} {}", color::console_level_prefix(level), text);
}
return;
}
@@ -109,7 +105,7 @@ pub fn print_response(resp: &Response, json_mode: bool) {
if let Some(errors) = data.get("errors").and_then(|v| v.as_array()) {
for err in errors {
let msg = err.get("message").and_then(|v| v.as_str()).unwrap_or("");
println!("\x1b[31m✗\x1b[0m {}", msg);
println!("{} {}", color::error_indicator(), msg);
}
return;
}
@@ -122,6 +118,27 @@ pub fn print_response(resp: &Response, json_mode: bool) {
}
return;
}
// Network requests
if let Some(requests) = data.get("requests").and_then(|v| v.as_array()) {
if requests.is_empty() {
println!("No requests captured");
} else {
for req in requests {
let method = req.get("method").and_then(|v| v.as_str()).unwrap_or("GET");
let url = req.get("url").and_then(|v| v.as_str()).unwrap_or("");
let resource_type = req.get("resourceType").and_then(|v| v.as_str()).unwrap_or("");
println!("{} {} ({})", method, url, resource_type);
}
}
return;
}
// Cleared requests
if let Some(cleared) = data.get("cleared").and_then(|v| v.as_bool()) {
if cleared {
println!("\x1b[32m✓\x1b[0m Request log cleared");
return;
}
}
// Bounding box
if let Some(box_data) = data.get("box") {
println!(
@@ -130,18 +147,91 @@ pub fn print_response(resp: &Response, json_mode: bool) {
);
return;
}
// Closed
if data.get("closed").is_some() {
println!("\x1b[32m✓\x1b[0m Browser closed");
// Element styles
if let Some(elements) = data.get("elements").and_then(|v| v.as_array()) {
for (i, el) in elements.iter().enumerate() {
let tag = el.get("tag").and_then(|v| v.as_str()).unwrap_or("?");
let text = el.get("text").and_then(|v| v.as_str()).unwrap_or("");
println!("[{}] {} \"{}\"", i, tag, text);
if let Some(box_data) = el.get("box") {
let w = box_data.get("width").and_then(|v| v.as_i64()).unwrap_or(0);
let h = box_data.get("height").and_then(|v| v.as_i64()).unwrap_or(0);
let x = box_data.get("x").and_then(|v| v.as_i64()).unwrap_or(0);
let y = box_data.get("y").and_then(|v| v.as_i64()).unwrap_or(0);
println!(" box: {}x{} at ({}, {})", w, h, x, y);
}
if let Some(styles) = el.get("styles") {
let font_size = styles.get("fontSize").and_then(|v| v.as_str()).unwrap_or("");
let font_weight = styles.get("fontWeight").and_then(|v| v.as_str()).unwrap_or("");
let font_family = styles.get("fontFamily").and_then(|v| v.as_str()).unwrap_or("");
let color = styles.get("color").and_then(|v| v.as_str()).unwrap_or("");
let bg = styles.get("backgroundColor").and_then(|v| v.as_str()).unwrap_or("");
let radius = styles.get("borderRadius").and_then(|v| v.as_str()).unwrap_or("");
println!(" font: {} {} {}", font_size, font_weight, font_family);
println!(" color: {}", color);
println!(" background: {}", bg);
if radius != "0px" {
println!(" border-radius: {}", radius);
}
}
println!();
}
return;
}
// Screenshot path
// Closed
if data.get("closed").is_some() {
println!("{} Browser closed", color::success_indicator());
return;
}
// 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 Screenshot saved to {}", path);
println!("{} Recording started: {}", color::success_indicator(), path);
} else {
println!("{} Recording started", color::success_indicator());
}
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!("{} Recording restarted: {} (previous saved to {})", color::success_indicator(), path, prev_path);
} else {
println!("{} Recording started: {}", color::success_indicator(), 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!("{} Recording saved to {} - {}", color::warning_indicator(), path, error);
} else {
println!("{} Recording saved to {}", color::success_indicator(), path);
}
} else {
println!("{} Recording stopped", color::success_indicator());
}
return;
}
// Screenshot path (no "started" or "frames" field)
if let Some(path) = data.get("path").and_then(|v| v.as_str()) {
println!("{} Screenshot saved to {}", color::success_indicator(), color::green(path));
return;
}
// Screenshot base64
if let Some(base64) = data.get("base64").and_then(|v| v.as_str()) {
println!("{}", base64);
return;
}
// Default success
println!("\x1b[32m✓\x1b[0m Done");
println!("{} Done", color::success_indicator());
}
}
@@ -350,9 +440,9 @@ Examples:
"select" => r##"
agent-browser select - Select a dropdown option
Usage: agent-browser select <selector> <value>
Usage: agent-browser select <selector> <value...>
Selects an option in a <select> dropdown by its value attribute.
Selects one or more options in a <select> dropdown by value.
Global Options:
--json Output as JSON
@@ -361,6 +451,7 @@ Global Options:
Examples:
agent-browser select "#country" "US"
agent-browser select @e5 "option2"
agent-browser select "#menu" "opt1" "opt2" "opt3"
"##,
"drag" => r##"
agent-browser drag - Drag and drop
@@ -642,6 +733,7 @@ Subcommands:
url Get current URL
count <selector> Count matching elements
box <selector> Get bounding box (x, y, width, height)
styles <selector> Get computed styles of elements
Global Options:
--json Output as JSON
@@ -656,6 +748,8 @@ Examples:
agent-browser get url
agent-browser get count "li.item"
agent-browser get box "#header"
agent-browser get styles "button"
agent-browser get styles @e1
"##,
// === Is ===
@@ -979,6 +1073,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
@@ -1121,7 +1251,7 @@ Core Commands:
focus <sel> Focus element
check <sel> Check checkbox
uncheck <sel> Uncheck checkbox
select <sel> <val> Select dropdown option
select <sel> <val...> Select dropdown option
drag <src> <dst> Drag and drop
upload <sel> <files...> Upload files
scroll <dir> [px] Scroll (up/down/left/right)
@@ -1131,6 +1261,7 @@ Core Commands:
pdf <path> Save as PDF
snapshot Accessibility tree with refs (for AI)
eval <js> Run JavaScript
connect <port> Connect to browser via CDP (e.g., connect 9222)
close Close browser
Navigation:
@@ -1139,7 +1270,7 @@ Navigation:
reload Reload page
Get Info: agent-browser get <what> [selector]
text, html, value, attr <name>, title, url, count, box
text, html, value, attr <name>, title, url, count, box, styles
Check State: agent-browser is <what> <selector>
visible, enabled, checked
@@ -1169,6 +1300,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
@@ -1192,11 +1325,13 @@ Options:
--headers <json> HTTP headers scoped to URL's origin (for auth)
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
--extension <path> Load browser extensions (repeatable).
--proxy <url> Proxy server (http://[user:pass@]host:port)
--json JSON output
--full, -f Full page screenshot
--headed Show browser window (not headless)
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
--debug Debug output
--version, -V Show version
Environment:
AGENT_BROWSER_SESSION Session name (default: "default")
@@ -1215,3 +1350,7 @@ Examples:
"#
);
}
pub fn print_version() {
println!("agent-browser {}", env!("CARGO_PKG_VERSION"));
}
+9 -5
View File
@@ -6,12 +6,16 @@ export default function CDPMode() {
<div className="prose">
<h1>CDP Mode</h1>
<p>Connect to an existing browser via Chrome DevTools Protocol:</p>
<CodeBlock code={`# Connect to Electron app
agent-browser --cdp 9222 snapshot
<CodeBlock code={`# Start Chrome with: google-chrome --remote-debugging-port=9222
# Connect to Chrome with remote debugging
# (Start Chrome with: google-chrome --remote-debugging-port=9222)
agent-browser --cdp 9222 open about:blank`} />
# Connect once, then run commands without --cdp
agent-browser connect 9222
agent-browser snapshot
agent-browser tab
agent-browser close
# Or pass --cdp on each command
agent-browser --cdp 9222 snapshot`} />
<h2>Use cases</h2>
<p>This enables control of:</p>
+79
View File
@@ -0,0 +1,79 @@
import { CodeBlock } from "@/components/code-block";
export default function Changelog() {
return (
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
<div className="prose">
<h1>Changelog</h1>
<h2 id="v0.6.0">v0.6.0</h2>
<p className="text-[#888] text-sm">January 2026</p>
<h3>New Features</h3>
<ul>
<li>
<strong>Video recording</strong> - Record browser sessions to WebM using Playwright&apos;s native recording
<CodeBlock code={`agent-browser record start ./demo.webm
agent-browser click @e1
agent-browser record stop`} />
</li>
<li>
<strong><code>connect</code> command</strong> - Connect to a browser via CDP and persist the connection for subsequent commands
<CodeBlock code={`agent-browser connect 9222
agent-browser snapshot # No --cdp needed after connect`} />
</li>
<li>
<strong><code>--proxy</code> flag</strong> - Configure browser proxy with optional authentication
<CodeBlock code="agent-browser --proxy http://user:pass@proxy.com:8080 open example.com" />
</li>
<li>
<strong><code>get styles</code> command</strong> - Extract computed styles from elements
<CodeBlock code={`agent-browser get styles "button"`} />
</li>
<li>
<strong>Claude marketplace plugin</strong> - Added <code>.claude-plugin/marketplace.json</code> for Claude Code integration
</li>
<li>
<strong>Enhanced network output</strong> - <code>network requests</code> now shows method, URL, and resource type
</li>
<li>
<strong><code>--version</code> flag</strong> - Display CLI version
</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li>Fix Windows daemon startup and port calculation</li>
<li>Support <code>libasound2t64</code> on newer Ubuntu versions (24.04+)</li>
<li>Prevent CDP timeout on empty URL tabs</li>
<li>Output screenshot as base64 when no path provided</li>
<li>Resolve refs in <code>get value</code> command</li>
<li>Support URL parameter in <code>tab new</code> command</li>
<li>Allow <code>about:</code>, <code>data:</code>, and <code>file:</code> URL schemes</li>
<li>Detect stale unix socket by attempting connection</li>
<li>Respect <code>AGENT_BROWSER_HEADED</code> environment variable</li>
<li>Handle SIGPIPE to prevent panic when piping to <code>head</code>/<code>tail</code></li>
<li>Fix null path validation in screenshot command</li>
</ul>
<h3>Protocol Alignment</h3>
<p>These changes align the CLI with the daemon protocol for consistency:</p>
<ul>
<li><code>select</code> command now uses <code>values</code> field (supports multiple selections)</li>
<li><code>frame main</code> uses <code>mainframe</code> action</li>
<li><code>mouse wheel</code> uses <code>wheel</code> action</li>
<li><code>set media</code> uses <code>emulatemedia</code> action</li>
<li>Console output uses <code>messages</code> field</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Expanded SKILL.md with comprehensive command reference</li>
<li>Updated README with new commands and options</li>
<li>Updated CDP mode documentation with <code>connect</code> workflow</li>
</ul>
</div>
</div>
);
}
+2 -1
View File
@@ -12,7 +12,8 @@ agent-browser snapshot # Get accessibility tree with refs
agent-browser click @e2 # Click by ref from snapshot
agent-browser fill @e3 "test@example.com" # Fill by ref
agent-browser get text @e1 # Get text by ref
agent-browser screenshot page.png
agent-browser screenshot # Base64 png to stdout
agent-browser screenshot page.png # Save to file
agent-browser close`} />
<h2>Traditional selectors</h2>
+1
View File
@@ -15,6 +15,7 @@ const navigation = [
{ name: "Streaming", href: "/streaming" },
{ name: "Agent Mode", href: "/agent-mode" },
{ name: "CDP Mode", href: "/cdp-mode" },
{ name: "Changelog", href: "/changelog" },
];
export function Sidebar() {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agent-browser",
"version": "0.4.4",
"version": "0.5.0",
"description": "Headless browser automation CLI for AI agents",
"type": "module",
"main": "dist/daemon.js",
+110 -1
View File
@@ -1,6 +1,7 @@
---
name: agent-browser
description: Automates browser interactions for web testing, form filling, screenshots, and data extraction. Use when the user needs to navigate websites, interact with web pages, fill forms, take screenshots, test web applications, or extract information from web pages.
allowed-tools: Bash(agent-browser:*)
---
# Browser Automation with agent-browser
@@ -39,45 +40,82 @@ agent-browser snapshot # Full accessibility tree
agent-browser snapshot -i # Interactive elements only (recommended)
agent-browser snapshot -c # Compact output
agent-browser snapshot -d 3 # Limit depth to 3
agent-browser snapshot -s "#main" # Scope to CSS selector
```
### Interactions (use @refs from snapshot)
```bash
agent-browser click @e1 # Click
agent-browser dblclick @e1 # Double-click
agent-browser focus @e1 # Focus element
agent-browser fill @e2 "text" # Clear and type
agent-browser type @e2 "text" # Type without clearing
agent-browser press Enter # Press key
agent-browser press Control+a # Key combination
agent-browser keydown Shift # Hold key down
agent-browser keyup Shift # Release key
agent-browser hover @e1 # Hover
agent-browser check @e1 # Check checkbox
agent-browser uncheck @e1 # Uncheck checkbox
agent-browser select @e1 "value" # Select dropdown
agent-browser scroll down 500 # Scroll page
agent-browser scrollintoview @e1 # Scroll element into view
agent-browser drag @e1 @e2 # Drag and drop
agent-browser upload @e1 file.pdf # Upload files
```
### Get information
```bash
agent-browser get text @e1 # Get element text
agent-browser get html @e1 # Get innerHTML
agent-browser get value @e1 # Get input value
agent-browser get attr @e1 href # Get attribute
agent-browser get title # Get page title
agent-browser get url # Get current URL
agent-browser get count ".item" # Count matching elements
agent-browser get box @e1 # Get bounding box
```
### Screenshots
### Check state
```bash
agent-browser is visible @e1 # Check if visible
agent-browser is enabled @e1 # Check if enabled
agent-browser is checked @e1 # Check if checked
```
### Screenshots & PDF
```bash
agent-browser screenshot # Screenshot to stdout
agent-browser screenshot path.png # Save to file
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
agent-browser wait 2000 # Wait milliseconds
agent-browser wait --text "Success" # Wait for text
agent-browser wait --url "**/dashboard" # Wait for URL pattern
agent-browser wait --load networkidle # Wait for network idle
agent-browser wait --fn "window.ready" # Wait for JS condition
```
### Mouse control
```bash
agent-browser mouse move 100 200 # Move mouse
agent-browser mouse down left # Press button
agent-browser mouse up left # Release button
agent-browser mouse wheel 100 # Scroll wheel
```
### Semantic locators (alternative to refs)
@@ -85,6 +123,66 @@ agent-browser wait --load networkidle # Wait for network idle
agent-browser find role button click --name "Submit"
agent-browser find text "Sign In" click
agent-browser find label "Email" fill "user@test.com"
agent-browser find first ".item" click
agent-browser find nth 2 "a" text
```
### Browser settings
```bash
agent-browser set viewport 1920 1080 # Set viewport size
agent-browser set device "iPhone 14" # Emulate device
agent-browser set geo 37.7749 -122.4194 # Set geolocation
agent-browser set offline on # Toggle offline mode
agent-browser set headers '{"X-Key":"v"}' # Extra HTTP headers
agent-browser set credentials user pass # HTTP basic auth
agent-browser set media dark # Emulate color scheme
```
### Cookies & Storage
```bash
agent-browser cookies # Get all cookies
agent-browser cookies set name value # Set cookie
agent-browser cookies clear # Clear cookies
agent-browser storage local # Get all localStorage
agent-browser storage local key # Get specific key
agent-browser storage local set k v # Set value
agent-browser storage local clear # Clear all
```
### Network
```bash
agent-browser network route <url> # Intercept requests
agent-browser network route <url> --abort # Block requests
agent-browser network route <url> --body '{}' # Mock response
agent-browser network unroute [url] # Remove routes
agent-browser network requests # View tracked requests
agent-browser network requests --filter api # Filter requests
```
### Tabs & Windows
```bash
agent-browser tab # List tabs
agent-browser tab new [url] # New tab
agent-browser tab 2 # Switch to tab
agent-browser tab close # Close tab
agent-browser window new # New window
```
### Frames
```bash
agent-browser frame "#iframe" # Switch to iframe
agent-browser frame main # Back to main frame
```
### Dialogs
```bash
agent-browser dialog accept [text] # Accept dialog
agent-browser dialog dismiss # Dismiss dialog
```
### JavaScript
```bash
agent-browser eval "document.title" # Run JavaScript
```
## Example: Form submission
@@ -140,4 +238,15 @@ agent-browser get text @e1 --json
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
agent-browser console --clear # Clear console
agent-browser errors # View page errors
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
```
+117 -3
View File
@@ -26,6 +26,7 @@ import type {
SelectCommand,
HoverCommand,
ContentCommand,
TabNewCommand,
TabSwitchCommand,
TabCloseCommand,
WindowNewCommand,
@@ -49,6 +50,7 @@ import type {
IsCheckedCommand,
CountCommand,
BoundingBoxCommand,
StylesCommand,
TraceStartCommand,
TraceStopCommand,
HarStopCommand,
@@ -99,6 +101,9 @@ import type {
InputMouseCommand,
InputKeyboardCommand,
InputTouchCommand,
RecordingStartCommand,
RecordingStopCommand,
RecordingRestartCommand,
NavigateData,
ScreenshotData,
EvaluateData,
@@ -109,7 +114,11 @@ import type {
TabCloseData,
ScreencastStartData,
ScreencastStopData,
RecordingStartData,
RecordingStopData,
RecordingRestartData,
InputEventData,
StylesData,
} from './types.js';
import { successResponse, errorResponse } from './protocol.js';
@@ -311,6 +320,8 @@ export async function executeCommand(command: Command, browser: BrowserManager):
return await handleCount(command, browser);
case 'boundingbox':
return await handleBoundingBox(command, browser);
case 'styles':
return await handleStyles(command, browser);
case 'video_start':
return await handleVideoStart(command, browser);
case 'video_stop':
@@ -427,6 +438,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 };
@@ -697,10 +714,17 @@ async function handleClose(
}
async function handleTabNew(
command: Command & { action: 'tab_new' },
command: TabNewCommand,
browser: BrowserManager
): Promise<Response<TabNewData>> {
const result = await browser.newTab();
// Navigate to URL if provided (same pattern as handleNavigate)
if (command.url) {
const page = browser.getPage();
await page.goto(command.url, { waitUntil: 'domcontentloaded' });
}
return successResponse(command.id, result);
}
@@ -1226,6 +1250,62 @@ async function handleBoundingBox(
return successResponse(command.id, { box });
}
async function handleStyles(
command: StylesCommand,
browser: BrowserManager
): Promise<Response<StylesData>> {
const page = browser.getPage();
// Shared extraction logic as a string to be eval'd in browser context
const extractStylesScript = `(function(el) {
const s = getComputedStyle(el);
const r = el.getBoundingClientRect();
return {
tag: el.tagName.toLowerCase(),
text: el.innerText?.trim().slice(0, 80) || null,
box: {
x: Math.round(r.x),
y: Math.round(r.y),
width: Math.round(r.width),
height: Math.round(r.height),
},
styles: {
fontSize: s.fontSize,
fontWeight: s.fontWeight,
fontFamily: s.fontFamily.split(',')[0].trim().replace(/"/g, ''),
color: s.color,
backgroundColor: s.backgroundColor,
borderRadius: s.borderRadius,
border: s.border !== 'none' && s.borderWidth !== '0px' ? s.border : null,
boxShadow: s.boxShadow !== 'none' ? s.boxShadow : null,
padding: s.padding,
},
};
})`;
// Check if it's a ref - single element
if (browser.isRef(command.selector)) {
const locator = browser.getLocator(command.selector);
const element = (await locator.evaluate((el, script) => {
const fn = eval(script);
return fn(el);
}, extractStylesScript)) as StylesData['elements'][0];
return successResponse(command.id, { elements: [element] });
}
// CSS selector - can match multiple elements
const elements = (await page.$$eval(
command.selector,
(els, script) => {
const fn = eval(script);
return els.map((el) => fn(el));
},
extractStylesScript
)) as StylesData['elements'];
return successResponse(command.id, { elements });
}
// Advanced handlers
async function handleVideoStart(
@@ -1424,8 +1504,8 @@ async function handleInputValue(
command: InputValueCommand,
browser: BrowserManager
): Promise<Response> {
const page = browser.getPage();
const value = await page.locator(command.selector).inputValue();
const locator = browser.getLocator(command.selector);
const value = await locator.inputValue();
return successResponse(command.id, { value });
}
@@ -1886,3 +1966,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,
});
}
+32 -2
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { BrowserManager } from './browser.js';
import { chromium } from 'playwright-core';
describe('BrowserManager', () => {
let browser: BrowserManager;
@@ -390,6 +391,35 @@ describe('BrowserManager', () => {
const cdp2 = await browser.getCDPSession();
expect(cdp1).toBe(cdp2);
});
it('should filter out pages with empty URLs during CDP connection', async () => {
const mockBrowser = {
contexts: () => [
{
pages: () => [
{ url: () => 'http://example.com', on: vi.fn() },
{ url: () => '', on: vi.fn() }, // This page should be filtered out
{ url: () => 'http://anothersite.com', on: vi.fn() },
],
on: vi.fn(),
},
],
close: vi.fn(),
};
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
const cdpBrowser = new BrowserManager();
await cdpBrowser.launch({ cdpPort: 9222 });
// Should have 2 pages, not 3
expect(cdpBrowser.getPages().length).toBe(2);
// Verify that the empty URL page is not in the list
const urls = cdpBrowser.getPages().map((p) => p.url());
expect(urls).not.toContain('');
expect(urls).toContain('http://example.com');
spy.mockRestore();
});
});
describe('screencast', () => {
@@ -405,7 +435,7 @@ describe('BrowserManager', () => {
expect(browser.isScreencasting()).toBe(true);
// Wait a bit for at least one frame
await new Promise((resolve) => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 1000));
await browser.stopScreencast();
expect(browser.isScreencasting()).toBe(false);
+245 -4
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
*/
@@ -682,6 +690,7 @@ export class BrowserManager {
args: [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`],
viewport,
extraHTTPHeaders: options.headers,
...(options.proxy && { proxy: options.proxy }),
}
);
this.isPersistentContext = true;
@@ -691,10 +700,14 @@ export class BrowserManager {
executablePath: options.executablePath,
});
this.cdpPort = null;
context = await this.browser.newContext({ viewport, extraHTTPHeaders: options.headers });
context = await this.browser.newContext({
viewport,
extraHTTPHeaders: options.headers,
...(options.proxy && { proxy: options.proxy }),
});
}
context.setDefaultTimeout(10000);
context.setDefaultTimeout(60000);
this.contexts.push(context);
const page = context.pages()[0] ?? (await context.newPage());
@@ -725,7 +738,9 @@ export class BrowserManager {
throw new Error('No browser context found. Make sure the app has an open window.');
}
const allPages = contexts.flatMap((context) => context.pages());
// Filter out pages with empty URLs, which can cause Playwright to hang
const allPages = contexts.flatMap((context) => context.pages()).filter((page) => page.url());
if (allPages.length === 0) {
throw new Error('No page found. Make sure the app has loaded content.');
}
@@ -828,7 +843,7 @@ export class BrowserManager {
const context = await this.browser.newContext({
viewport: viewport ?? { width: 1280, height: 720 },
});
context.setDefaultTimeout(10000);
context.setDefaultTimeout(60000);
this.contexts.push(context);
const page = await context.newPage();
@@ -1103,10 +1118,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();
+1 -1
View File
@@ -200,7 +200,7 @@ export async function startDaemon(options?: { streamPort?: number }): Promise<vo
await browser.launch({
id: 'auto',
action: 'launch',
headless: true,
headless: process.env.AGENT_BROWSER_HEADED !== '1',
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
extensions: extensions,
});
+24
View File
@@ -15,6 +15,22 @@ describe('parseCommand', () => {
}
});
it('should parse navigate with headers', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'navigate',
url: 'https://example.com',
headers: { Authorization: 'Bearer token' },
})
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe('navigate');
expect(result.command.headers).toEqual({ Authorization: 'Bearer token' });
}
});
it('should reject navigate without url', () => {
const result = parseCommand(cmd({ id: '1', action: 'navigate' }));
expect(result.success).toBe(false);
@@ -373,6 +389,14 @@ describe('parseCommand', () => {
expect(result.success).toBe(true);
});
it('should parse tab_new with url', () => {
const result = parseCommand(cmd({ id: '1', action: 'tab_new', url: 'https://example.com' }));
expect(result.success).toBe(true);
if (result.success) {
expect((result.command as { url?: string }).url).toBe('https://example.com');
}
});
it('should parse tab_list', () => {
const result = parseCommand(cmd({ id: '1', action: 'tab_list' }));
expect(result.success).toBe(true);
+40 -1
View File
@@ -19,12 +19,24 @@ const launchSchema = baseCommandSchema.extend({
.optional(),
browser: z.enum(['chromium', 'firefox', 'webkit']).optional(),
cdpPort: z.number().positive().optional(),
executablePath: z.string().optional(),
extensions: z.array(z.string()).optional(),
headers: z.record(z.string()).optional(),
proxy: z
.object({
server: z.string().min(1),
bypass: z.string().optional(),
username: z.string().optional(),
password: z.string().optional(),
})
.optional(),
});
const navigateSchema = baseCommandSchema.extend({
action: z.literal('navigate'),
url: z.string().min(1),
waitUntil: z.enum(['load', 'domcontentloaded', 'networkidle']).optional(),
headers: z.record(z.string()).optional(),
});
const clickSchema = baseCommandSchema.extend({
@@ -295,6 +307,11 @@ const boundingBoxSchema = baseCommandSchema.extend({
selector: z.string().min(1),
});
const stylesSchema = baseCommandSchema.extend({
action: z.literal('styles'),
selector: z.string().min(1),
});
const videoStartSchema = baseCommandSchema.extend({
action: z.literal('video_start'),
path: z.string().min(1),
@@ -304,6 +321,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(),
@@ -642,7 +676,7 @@ const pressSchema = baseCommandSchema.extend({
const screenshotSchema = baseCommandSchema.extend({
action: z.literal('screenshot'),
path: z.string().optional(),
path: z.string().nullable().optional(),
fullPage: z.boolean().optional(),
selector: z.string().min(1).optional(),
format: z.enum(['png', 'jpeg']).optional(),
@@ -702,6 +736,7 @@ const closeSchema = baseCommandSchema.extend({
// Tab/Window schemas
const tabNewSchema = baseCommandSchema.extend({
action: z.literal('tab_new'),
url: z.string().min(1).optional(),
});
const tabListSchema = baseCommandSchema.extend({
@@ -791,8 +826,12 @@ const commandSchema = z.discriminatedUnion('action', [
isCheckedSchema,
countSchema,
boundingBoxSchema,
stylesSchema,
videoStartSchema,
videoStopSchema,
recordingStartSchema,
recordingStopSchema,
recordingRestartSchema,
traceStartSchema,
traceStopSchema,
harStartSchema,
+75 -1
View File
@@ -16,6 +16,12 @@ export interface LaunchCommand extends BaseCommand {
executablePath?: string;
cdpPort?: number;
extensions?: string[];
proxy?: {
server: string;
bypass?: string;
username?: string;
password?: string;
};
}
export interface NavigateCommand extends BaseCommand {
@@ -310,6 +316,12 @@ export interface BoundingBoxCommand extends BaseCommand {
selector: string;
}
// Computed styles
export interface StylesCommand extends BaseCommand {
action: 'styles';
selector: string;
}
// More semantic locators
export interface GetByAltTextCommand extends BaseCommand {
action: 'getbyalttext';
@@ -502,7 +514,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;
@@ -512,6 +524,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';
@@ -749,6 +778,7 @@ export interface CloseCommand extends BaseCommand {
// Tab/Window commands
export interface TabNewCommand extends BaseCommand {
action: 'tab_new';
url?: string;
}
export interface TabListCommand extends BaseCommand {
@@ -833,8 +863,12 @@ export type Command =
| IsCheckedCommand
| CountCommand
| BoundingBoxCommand
| StylesCommand
| VideoStartCommand
| VideoStopCommand
| RecordingStartCommand
| RecordingStopCommand
| RecordingRestartCommand
| TraceStartCommand
| TraceStopCommand
| HarStartCommand
@@ -968,10 +1002,50 @@ 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;
}
// Element styles data
export interface ElementStyleInfo {
tag: string;
text: string | null;
box: { x: number; y: number; width: number; height: number };
styles: {
fontSize: string;
fontWeight: string;
fontFamily: string;
color: string;
backgroundColor: string;
borderRadius: string;
border: string | null;
boxShadow: string | null;
padding: string;
};
}
export interface StylesData {
elements: ElementStyleInfo[];
}
// Browser state
export interface BrowserState {
browser: Browser | null;