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 ## Code Style
- Do not use emojis in code, output, or documentation. Unicode symbols (✓, ✗, →, ⚠) are acceptable. - 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 --> <!-- 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 scrollintoview <sel> # Scroll element into view (alias: scrollinto)
agent-browser drag <src> <tgt> # Drag and drop agent-browser drag <src> <tgt> # Drag and drop
agent-browser upload <sel> <files> # Upload files 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 pdf <path> # Save as PDF
agent-browser snapshot # Accessibility tree with refs (best for AI) agent-browser snapshot # Accessibility tree with refs (best for AI)
agent-browser eval <js> # Run JavaScript agent-browser eval <js> # Run JavaScript
agent-browser connect <port> # Connect to browser via CDP
agent-browser close # Close browser (aliases: quit, exit) 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: Connect to an existing browser via Chrome DevTools Protocol:
```bash ```bash
# Connect to Electron app # Start Chrome with: google-chrome --remote-debugging-port=9222
agent-browser --cdp 9222 snapshot
# Connect to Chrome with remote debugging # Connect once, then run commands without --cdp
# (Start Chrome with: google-chrome --remote-debugging-port=9222) agent-browser connect 9222
agent-browser --cdp 9222 open about:blank agent-browser snapshot
agent-browser tab
agent-browser close
# Or pass --cdp on each command
agent-browser --cdp 9222 snapshot
``` ```
This enables control of: This enables control of:
+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]] [[package]]
name = "agent-browser" name = "agent-browser"
version = "0.4.4" version = "0.5.0"
dependencies = [ dependencies = [
"libc", "libc",
"serde", "serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "agent-browser" name = "agent-browser"
version = "0.4.4" version = "0.5.0"
edition = "2021" edition = "2021"
description = "Fast browser automation CLI for AI agents" description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0" 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(), context: cmd.to_string(),
usage: "open <url>", 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() url.to_string()
} else { } else {
format!("https://{}", url) format!("https://{}", url)
@@ -153,13 +158,18 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
"select" => { "select" => {
let sel = rest.get(0).ok_or_else(|| ParseError::MissingArguments { let sel = rest.get(0).ok_or_else(|| ParseError::MissingArguments {
context: "select".to_string(), 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(), 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" => { "drag" => {
let src = rest.get(0).ok_or_else(|| ParseError::MissingArguments { 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/PDF ===
"screenshot" => { "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" => { "pdf" => {
let path = rest.get(0).ok_or_else(|| ParseError::MissingArguments { 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 ===
"close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "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 ===
"get" => parse_get(&rest, &id), "get" => parse_get(&rest, &id),
@@ -367,10 +394,20 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === Tabs === // === Tabs ===
"tab" => { "tab" => {
match rest.get(0).map(|s| *s) { 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("list") => Ok(json!({ "id": id, "action": "tab_list" })),
Some("close") => { 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() => { Some(n) if n.parse::<i32>().is_ok() => {
Ok(json!({ "id": id, "action": "tab_switch", "index": n.parse::<i32>().unwrap() })) 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 ===
"frame" => { "frame" => {
if rest.get(0).map(|s| *s) == Some("main") { if rest.get(0).map(|s| *s) == Some("main") {
Ok(json!({ "id": id, "action": "frame_main" })) Ok(json!({ "id": id, "action": "mainframe" }))
} else { } else {
let sel = rest.get(0).ok_or_else(|| ParseError::MissingArguments { let sel = rest.get(0).ok_or_else(|| ParseError::MissingArguments {
context: "frame".to_string(), context: "frame".to_string(),
@@ -413,7 +450,11 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
const VALID: &[&str] = &["accept", "dismiss"]; const VALID: &[&str] = &["accept", "dismiss"];
match rest.get(0).map(|s| *s) { match rest.get(0).map(|s| *s) {
Some("accept") => { 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("dismiss") => Ok(json!({ "id": id, "action": "dialog", "response": "dismiss" })),
Some(sub) => Err(ParseError::UnknownSubcommand { Some(sub) => Err(ParseError::UnknownSubcommand {
@@ -431,8 +472,14 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
"trace" => { "trace" => {
const VALID: &[&str] = &["start", "stop"]; const VALID: &[&str] = &["start", "stop"];
match rest.get(0).map(|s| *s) { match rest.get(0).map(|s| *s) {
Some("start") => Ok(json!({ "id": id, "action": "trace_start", "path": rest.get(1) })), Some("start") => Ok(json!({ "id": id, "action": "trace_start" })),
Some("stop") => Ok(json!({ "id": id, "action": "trace_stop", "path": rest.get(1) })), 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 { Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(), subcommand: sub.to_string(),
valid_options: VALID, 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" => { "console" => {
let clear = rest.iter().any(|&s| s == "--clear"); let clear = rest.iter().any(|&s| s == "--clear");
Ok(json!({ "id": id, "action": "console", "clear": 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> { 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) { match rest.get(0).map(|s| *s) {
Some("text") => { Some("text") => {
@@ -546,13 +647,20 @@ fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
})?; })?;
Ok(json!({ "id": id, "action": "boundingbox", "selector": sel })) 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 { Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(), subcommand: sub.to_string(),
valid_options: VALID, valid_options: VALID,
}), }),
None => Err(ParseError::MissingArguments { None => Err(ParseError::MissingArguments {
context: "get".to_string(), 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 { 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 })), "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 })), "label" => {
"placeholder" => Ok(json!({ "id": id, "action": "getbyplaceholder", "placeholder": value, "subaction": subaction, "value": fill_value, "exact": exact })), 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 })), "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 })), "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 })), "testid" => {
"first" => Ok(json!({ "id": id, "action": "nth", "selector": value, "index": 0, "subaction": subaction, "value": fill_value })), let mut cmd = json!({ "id": id, "action": "getbytestid", "testId": value, "subaction": subaction });
"last" => Ok(json!({ "id": id, "action": "nth", "selector": value, "index": -1, "subaction": subaction, "value": fill_value })), 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!(), _ => unreachable!(),
} }
} }
@@ -661,7 +793,9 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
} else { } else {
None 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 { _ => Err(ParseError::UnknownSubcommand {
subcommand: locator.to_string(), subcommand: locator.to_string(),
@@ -702,7 +836,7 @@ fn parse_mouse(rest: &[&str], id: &str) -> Result<Value, ParseError> {
Some("wheel") => { Some("wheel") => {
let dy = rest.get(1).and_then(|s| s.parse::<i32>().ok()).unwrap_or(100); 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); 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 { Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(), subcommand: sub.to_string(),
@@ -800,8 +934,12 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
} else { } else {
"no-preference" "no-preference"
}; };
let reduced = rest.iter().any(|&s| s == "reduced-motion"); let reduced = if rest.iter().any(|&s| s == "reduced-motion") {
Ok(json!({ "id": id, "action": "media", "colorScheme": color, "reducedMotion": reduced })) "reduce"
} else {
"no-preference"
};
Ok(json!({ "id": id, "action": "emulatemedia", "colorScheme": color, "reducedMotion": reduced }))
} }
Some(sub) => Err(ParseError::UnknownSubcommand { Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(), 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)); 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 })) 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") => { Some("requests") => {
let clear = rest.iter().any(|&s| s == "--clear"); let clear = rest.iter().any(|&s| s == "--clear");
let filter_idx = rest.iter().position(|&s| s == "--filter"); let filter_idx = rest.iter().position(|&s| s == "--filter");
let filter = filter_idx.and_then(|i| rest.get(i + 1).map(|s| *s)); 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 { Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(), subcommand: sub.to_string(),
@@ -903,6 +1051,7 @@ mod tests {
executable_path: None, executable_path: None,
extensions: Vec::new(), extensions: Vec::new(),
cdp: None, cdp: None,
proxy: None,
} }
} }
@@ -1147,12 +1296,46 @@ mod tests {
assert_eq!(cmd["text"], "some text"); 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 === // === Tabs ===
#[test] #[test]
fn test_tab_new() { fn test_tab_new() {
let cmd = parse_command(&args("tab new"), &default_flags()).unwrap(); let cmd = parse_command(&args("tab new"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "tab_new"); 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] #[test]
@@ -1180,6 +1363,14 @@ mod tests {
fn test_screenshot() { fn test_screenshot() {
let cmd = parse_command(&args("screenshot"), &default_flags()).unwrap(); let cmd = parse_command(&args("screenshot"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "screenshot"); 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] #[test]
@@ -1273,6 +1464,82 @@ mod tests {
// === Unknown command === // === 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] #[test]
fn test_unknown_command() { fn test_unknown_command() {
let result = parse_command(&args("unknowncommand"), &default_flags()); let result = parse_command(&args("unknowncommand"), &default_flags());
@@ -1316,4 +1583,54 @@ mod tests {
assert!(matches!(err, ParseError::MissingArguments { .. })); assert!(matches!(err, ParseError::MissingArguments { .. }));
assert!(err.format().contains("get text")); 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() { for c in session.chars() {
hash = ((hash << 5).wrapping_sub(hash)).wrapping_add(c as i32); 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)] #[cfg(unix)]
@@ -140,7 +142,8 @@ fn is_daemon_running(session: &str) -> bool {
fn daemon_ready(session: &str) -> bool { fn daemon_ready(session: &str) -> bool {
#[cfg(unix)] #[cfg(unix)]
{ {
get_socket_path(session).exists() let socket_path = get_socket_path(session);
UnixStream::connect(&socket_path).is_ok()
} }
#[cfg(windows)] #[cfg(windows)]
{ {
@@ -174,16 +177,23 @@ pub fn ensure_daemon(
let exe_path = env::current_exe().map_err(|e| e.to_string())?; let exe_path = env::current_exe().map_err(|e| e.to_string())?;
let exe_dir = exe_path.parent().unwrap(); let exe_dir = exe_path.parent().unwrap();
let daemon_paths = [ let mut daemon_paths = vec![
exe_dir.join("daemon.js"), exe_dir.join("daemon.js"),
exe_dir.join("../dist/daemon.js"), exe_dir.join("../dist/daemon.js"),
PathBuf::from("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 let daemon_path = daemon_paths
.iter() .iter()
.find(|p| p.exists()) .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 // Spawn daemon as a fully detached background process
#[cfg(unix)] #[cfg(unix)]
@@ -227,13 +237,10 @@ pub fn ensure_daemon(
{ {
use std::os::windows::process::CommandExt; use std::os::windows::process::CommandExt;
// On Windows, use cmd.exe to run node to ensure proper PATH resolution. // On Windows, call node directly. Command::new handles PATH resolution (node.exe or node.cmd)
// This handles cases where node.exe isn't directly in PATH but node.cmd is. // and automatically quotes arguments containing spaces.
// Pass the entire command as a single string to /c to handle paths with spaces. let mut cmd = Command::new("node");
let cmd_string = format!("node \"{}\"", daemon_path.display()); cmd.arg(daemon_path)
let mut cmd = Command::new("cmd");
cmd.arg("/c")
.arg(&cmd_string)
.env("AGENT_BROWSER_DAEMON", "1") .env("AGENT_BROWSER_DAEMON", "1")
.env("AGENT_BROWSER_SESSION", session); .env("AGENT_BROWSER_SESSION", session);
+9 -1
View File
@@ -10,6 +10,7 @@ pub struct Flags {
pub executable_path: Option<String>, pub executable_path: Option<String>,
pub cdp: Option<String>, pub cdp: Option<String>,
pub extensions: Vec<String>, pub extensions: Vec<String>,
pub proxy: Option<String>,
} }
pub fn parse_flags(args: &[String]) -> Flags { 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(), executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(),
cdp: None, cdp: None,
extensions: extensions_env, extensions: extensions_env,
proxy: None,
}; };
let mut i = 0; let mut i = 0;
@@ -67,6 +69,12 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1; i += 1;
} }
} }
"--proxy" => {
if let Some(p) = args.get(i + 1) {
flags.proxy = Some(p.clone());
i += 1;
}
}
_ => {} _ => {}
} }
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 // Global flags that should be stripped from command args
const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"]; const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"];
// Global flags that take a value (need to skip the next arg too) // 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() { for arg in args.iter() {
if skip_next { if skip_next {
+32 -13
View File
@@ -1,3 +1,4 @@
use crate::color;
use std::process::{exit, Command, Stdio}; use std::process::{exit, Command, Stdio};
pub fn run_install(with_deps: bool) { pub fn run_install(with_deps: bool) {
@@ -5,9 +6,15 @@ pub fn run_install(with_deps: bool) {
if is_linux { if is_linux {
if with_deps { 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 (pkg_mgr, deps) = if which_exists("apt-get") {
let libasound = if package_exists_apt("libasound2t64") {
"libasound2t64"
} else {
"libasound2"
};
( (
"apt-get", "apt-get",
vec![ vec![
@@ -30,7 +37,7 @@ pub fn run_install(with_deps: bool) {
"libcairo2", "libcairo2",
"libgdk-pixbuf-2.0-0", "libgdk-pixbuf-2.0-0",
"libxrender1", "libxrender1",
"libasound2", libasound,
"libfreetype6", "libfreetype6",
"libfontconfig1", "libfontconfig1",
"libdbus-1-3", "libdbus-1-3",
@@ -93,7 +100,7 @@ pub fn run_install(with_deps: bool) {
], ],
) )
} else { } 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); exit(1);
}; };
@@ -112,22 +119,23 @@ pub fn run_install(with_deps: bool) {
match status { match status {
Ok(s) if s.success() => { Ok(s) if s.success() => {
println!("\x1b[32m✓\x1b[0m System dependencies installed") println!("{} System dependencies installed", color::success_indicator())
} }
Ok(_) => eprintln!( 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 { } 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!(" agent-browser install --with-deps");
println!(" or: npx playwright install-deps chromium"); println!(" or: npx playwright install-deps chromium");
println!(); 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 // 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. // 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 { match status {
Ok(s) if s.success() => { 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 { if is_linux && !with_deps {
println!(); 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"); println!(" agent-browser install --with-deps");
} }
} }
Ok(_) => { Ok(_) => {
eprintln!("\x1b[31m✗\x1b[0m Failed to install browser"); eprintln!("{} Failed to install browser", color::error_indicator());
if is_linux { 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"); println!(" agent-browser install --with-deps");
} }
exit(1); exit(1);
} }
Err(e) => { 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"); eprintln!("Make sure Node.js is installed and npx is in your PATH");
exit(1); exit(1);
} }
@@ -189,3 +197,14 @@ fn which_exists(cmd: &str) -> bool {
.unwrap_or(false) .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 commands;
mod connection; mod connection;
mod flags; mod flags;
@@ -21,7 +22,37 @@ use commands::{gen_id, parse_command, ParseError};
use connection::{ensure_daemon, send_command}; use connection::{ensure_daemon, send_command};
use flags::{clean_args, parse_flags}; use flags::{clean_args, parse_flags};
use install::run_install; 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) { fn run_session(args: &[String], session: &str, json_mode: bool) {
let subcommand = args.get(1).map(|s| s.as_str()); 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 { } else {
println!("Active sessions:"); println!("Active sessions:");
for s in &sessions { for s in &sessions {
let marker = if s == session { "" } else { " " }; let marker = if s == session { color::cyan("") } else { " ".to_string() };
println!("{} {}", marker, s); println!("{} {}", marker, s);
} }
} }
@@ -94,16 +125,18 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
} }
fn main() { 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 args: Vec<String> = env::args().skip(1).collect();
let flags = parse_flags(&args); let flags = parse_flags(&args);
let clean = clean_args(&args); let clean = clean_args(&args);
let has_help = args.iter().any(|a| a == "--help" || a == "-h"); let has_help = args.iter().any(|a| a == "--help" || a == "-h");
let has_version = args.iter().any(|a| a == "--version" || a == "-V");
if clean.is_empty() {
print_help();
return;
}
if has_help { if has_help {
if let Some(cmd) = clean.get(0) { if let Some(cmd) = clean.get(0) {
@@ -115,6 +148,16 @@ fn main() {
return; return;
} }
if has_version {
print_version();
return;
}
if clean.is_empty() {
print_help();
return;
}
// Handle install separately // Handle install separately
if clean.get(0).map(|s| s.as_str()) == Some("install") { if clean.get(0).map(|s| s.as_str()) == Some("install") {
let with_deps = args.iter().any(|a| a == "--with-deps" || a == "-d"); let with_deps = args.iter().any(|a| a == "--with-deps" || a == "-d");
@@ -143,7 +186,7 @@ fn main() {
error_type error_type
); );
} else { } else {
eprintln!("\x1b[31m{}\x1b[0m", e.format()); eprintln!("{}", color::red(&e.format()));
} }
exit(1); exit(1);
} }
@@ -155,7 +198,7 @@ fn main() {
if flags.json { if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, e); println!(r#"{{"success":false,"error":"{}"}}"#, e);
} else { } else {
eprintln!("\x1b[31m✗\x1b[0m {}", e); eprintln!("{} {}", color::error_indicator(), e);
} }
exit(1); exit(1);
} }
@@ -165,10 +208,10 @@ fn main() {
if daemon_result.already_running && (flags.executable_path.is_some() || !flags.extensions.is_empty()) { if daemon_result.already_running && (flags.executable_path.is_some() || !flags.extensions.is_empty()) {
if !flags.json { if !flags.json {
if flags.executable_path.is_some() { 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() { 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 { if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg); println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else { } else {
eprintln!("\x1b[31m✗\x1b[0m {}", msg); eprintln!("{} {}", color::error_indicator(), msg);
} }
exit(1); exit(1);
} }
@@ -190,7 +233,7 @@ fn main() {
if flags.json { if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg); println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else { } else {
eprintln!("\x1b[31m✗\x1b[0m {}", msg); eprintln!("{} {}", color::error_indicator(), msg);
} }
exit(1); exit(1);
} }
@@ -200,7 +243,7 @@ fn main() {
if flags.json { if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg); println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else { } else {
eprintln!("\x1b[31m✗\x1b[0m {}", msg); eprintln!("{} {}", color::error_indicator(), msg);
} }
exit(1); exit(1);
} }
@@ -222,23 +265,30 @@ fn main() {
if flags.json { if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg); println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else { } else {
eprintln!("\x1b[31m✗\x1b[0m {}", msg); eprintln!("{} {}", color::error_indicator(), msg);
} }
exit(1); exit(1);
} }
} }
// Launch headed browser if --headed flag is set (without CDP) // Launch headed browser or proxy if flags are set (without CDP)
if flags.headed && flags.cdp.is_none() { if (flags.headed || flags.proxy.is_some()) && flags.cdp.is_none() {
let launch_cmd = json!({ let mut launch_cmd = json!({
"id": gen_id(), "id": gen_id(),
"action": "launch", "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 let Err(e) = send_command(launch_cmd, &flags.session) {
if !flags.json { 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 { if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, e); println!(r#"{{"success":false,"error":"{}"}}"#, e);
} else { } else {
eprintln!("\x1b[31m✗\x1b[0m {}", e); eprintln!("{} {}", color::error_indicator(), e);
} }
exit(1); 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; use crate::connection::Response;
pub fn print_response(resp: &Response, json_mode: bool) { 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 { if !resp.success {
eprintln!( eprintln!(
"\x1b[31m✗\x1b[0m {}", "{} {}",
color::error_indicator(),
resp.error.as_deref().unwrap_or("Unknown error") resp.error.as_deref().unwrap_or("Unknown error")
); );
return; return;
@@ -18,8 +20,8 @@ pub fn print_response(resp: &Response, json_mode: bool) {
// Navigation response // Navigation response
if let Some(url) = data.get("url").and_then(|v| v.as_str()) { 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()) { if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
println!("\x1b[32m✓\x1b[0m \x1b[1m{}\x1b[0m", title); println!("{} {}", color::success_indicator(), color::bold(title));
println!("\x1b[2m {}\x1b[0m", url); println!(" {}", color::dim(url));
return; return;
} }
println!("{}", url); println!("{}", url);
@@ -85,23 +87,17 @@ pub fn print_response(resp: &Response, json_mode: bool) {
.unwrap_or("Untitled"); .unwrap_or("Untitled");
let url = tab.get("url").and_then(|v| v.as_str()).unwrap_or(""); 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 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); println!("{} [{}] {} - {}", marker, i, title, url);
} }
return; return;
} }
// Console logs // 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 { for log in logs {
let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log"); 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 text = log.get("text").and_then(|v| v.as_str()).unwrap_or("");
let color = match level { println!("{} {}", color::console_level_prefix(level), text);
"error" => "\x1b[31m",
"warning" => "\x1b[33m",
"info" => "\x1b[36m",
_ => "\x1b[0m",
};
println!("{}[{}]\x1b[0m {}", color, level, text);
} }
return; 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()) { if let Some(errors) = data.get("errors").and_then(|v| v.as_array()) {
for err in errors { for err in errors {
let msg = err.get("message").and_then(|v| v.as_str()).unwrap_or(""); 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; return;
} }
@@ -122,6 +118,27 @@ pub fn print_response(resp: &Response, json_mode: bool) {
} }
return; 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 // Bounding box
if let Some(box_data) = data.get("box") { if let Some(box_data) = data.get("box") {
println!( println!(
@@ -130,18 +147,91 @@ pub fn print_response(resp: &Response, json_mode: bool) {
); );
return; return;
} }
// Closed // Element styles
if data.get("closed").is_some() { if let Some(elements) = data.get("elements").and_then(|v| v.as_array()) {
println!("\x1b[32m✓\x1b[0m Browser closed"); 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; 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!("{} 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()) { if let Some(path) = data.get("path").and_then(|v| v.as_str()) {
println!("\x1b[32m✓\x1b[0m Screenshot saved to {}", path); 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; return;
} }
// Default success // Default success
println!("\x1b[32m✓\x1b[0m Done"); println!("{} Done", color::success_indicator());
} }
} }
@@ -350,9 +440,9 @@ Examples:
"select" => r##" "select" => r##"
agent-browser select - Select a dropdown option 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: Global Options:
--json Output as JSON --json Output as JSON
@@ -361,6 +451,7 @@ Global Options:
Examples: Examples:
agent-browser select "#country" "US" agent-browser select "#country" "US"
agent-browser select @e5 "option2" agent-browser select @e5 "option2"
agent-browser select "#menu" "opt1" "opt2" "opt3"
"##, "##,
"drag" => r##" "drag" => r##"
agent-browser drag - Drag and drop agent-browser drag - Drag and drop
@@ -642,6 +733,7 @@ Subcommands:
url Get current URL url Get current URL
count <selector> Count matching elements count <selector> Count matching elements
box <selector> Get bounding box (x, y, width, height) box <selector> Get bounding box (x, y, width, height)
styles <selector> Get computed styles of elements
Global Options: Global Options:
--json Output as JSON --json Output as JSON
@@ -656,6 +748,8 @@ Examples:
agent-browser get url agent-browser get url
agent-browser get count "li.item" agent-browser get count "li.item"
agent-browser get box "#header" agent-browser get box "#header"
agent-browser get styles "button"
agent-browser get styles @e1
"##, "##,
// === Is === // === Is ===
@@ -979,6 +1073,42 @@ Examples:
agent-browser trace stop ./debug-trace.zip 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/Errors ===
"console" => r##" "console" => r##"
agent-browser console - View console logs agent-browser console - View console logs
@@ -1121,7 +1251,7 @@ Core Commands:
focus <sel> Focus element focus <sel> Focus element
check <sel> Check checkbox check <sel> Check checkbox
uncheck <sel> Uncheck checkbox uncheck <sel> Uncheck checkbox
select <sel> <val> Select dropdown option select <sel> <val...> Select dropdown option
drag <src> <dst> Drag and drop drag <src> <dst> Drag and drop
upload <sel> <files...> Upload files upload <sel> <files...> Upload files
scroll <dir> [px] Scroll (up/down/left/right) scroll <dir> [px] Scroll (up/down/left/right)
@@ -1131,6 +1261,7 @@ Core Commands:
pdf <path> Save as PDF pdf <path> Save as PDF
snapshot Accessibility tree with refs (for AI) snapshot Accessibility tree with refs (for AI)
eval <js> Run JavaScript eval <js> Run JavaScript
connect <port> Connect to browser via CDP (e.g., connect 9222)
close Close browser close Close browser
Navigation: Navigation:
@@ -1139,7 +1270,7 @@ Navigation:
reload Reload page reload Reload page
Get Info: agent-browser get <what> [selector] 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> Check State: agent-browser is <what> <selector>
visible, enabled, checked visible, enabled, checked
@@ -1169,6 +1300,8 @@ Tabs:
Debug: Debug:
trace start|stop [path] Record trace 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 console [--clear] View console logs
errors [--clear] View page errors errors [--clear] View page errors
highlight <sel> Highlight element highlight <sel> Highlight element
@@ -1192,11 +1325,13 @@ Options:
--headers <json> HTTP headers scoped to URL's origin (for auth) --headers <json> HTTP headers scoped to URL's origin (for auth)
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH) --executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
--extension <path> Load browser extensions (repeatable). --extension <path> Load browser extensions (repeatable).
--proxy <url> Proxy server (http://[user:pass@]host:port)
--json JSON output --json JSON output
--full, -f Full page screenshot --full, -f Full page screenshot
--headed Show browser window (not headless) --headed Show browser window (not headless)
--cdp <port> Connect via CDP (Chrome DevTools Protocol) --cdp <port> Connect via CDP (Chrome DevTools Protocol)
--debug Debug output --debug Debug output
--version, -V Show version
Environment: Environment:
AGENT_BROWSER_SESSION Session name (default: "default") 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"> <div className="prose">
<h1>CDP Mode</h1> <h1>CDP Mode</h1>
<p>Connect to an existing browser via Chrome DevTools Protocol:</p> <p>Connect to an existing browser via Chrome DevTools Protocol:</p>
<CodeBlock code={`# Connect to Electron app <CodeBlock code={`# Start Chrome with: google-chrome --remote-debugging-port=9222
agent-browser --cdp 9222 snapshot
# Connect to Chrome with remote debugging # Connect once, then run commands without --cdp
# (Start Chrome with: google-chrome --remote-debugging-port=9222) agent-browser connect 9222
agent-browser --cdp 9222 open about:blank`} /> agent-browser snapshot
agent-browser tab
agent-browser close
# Or pass --cdp on each command
agent-browser --cdp 9222 snapshot`} />
<h2>Use cases</h2> <h2>Use cases</h2>
<p>This enables control of:</p> <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 click @e2 # Click by ref from snapshot
agent-browser fill @e3 "test@example.com" # Fill by ref agent-browser fill @e3 "test@example.com" # Fill by ref
agent-browser get text @e1 # Get text 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`} /> agent-browser close`} />
<h2>Traditional selectors</h2> <h2>Traditional selectors</h2>
+1
View File
@@ -15,6 +15,7 @@ const navigation = [
{ name: "Streaming", href: "/streaming" }, { name: "Streaming", href: "/streaming" },
{ name: "Agent Mode", href: "/agent-mode" }, { name: "Agent Mode", href: "/agent-mode" },
{ name: "CDP Mode", href: "/cdp-mode" }, { name: "CDP Mode", href: "/cdp-mode" },
{ name: "Changelog", href: "/changelog" },
]; ];
export function Sidebar() { export function Sidebar() {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agent-browser", "name": "agent-browser",
"version": "0.4.4", "version": "0.5.0",
"description": "Headless browser automation CLI for AI agents", "description": "Headless browser automation CLI for AI agents",
"type": "module", "type": "module",
"main": "dist/daemon.js", "main": "dist/daemon.js",
+115 -6
View File
@@ -1,6 +1,7 @@
--- ---
name: agent-browser 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. 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 # Browser Automation with agent-browser
@@ -28,56 +29,93 @@ agent-browser close # Close browser
```bash ```bash
agent-browser open <url> # Navigate to URL agent-browser open <url> # Navigate to URL
agent-browser back # Go back agent-browser back # Go back
agent-browser forward # Go forward agent-browser forward # Go forward
agent-browser reload # Reload page agent-browser reload # Reload page
agent-browser close # Close browser agent-browser close # Close browser
``` ```
### Snapshot (page analysis) ### Snapshot (page analysis)
```bash ```bash
agent-browser snapshot # Full accessibility tree agent-browser snapshot # Full accessibility tree
agent-browser snapshot -i # Interactive elements only (recommended) agent-browser snapshot -i # Interactive elements only (recommended)
agent-browser snapshot -c # Compact output agent-browser snapshot -c # Compact output
agent-browser snapshot -d 3 # Limit depth to 3 agent-browser snapshot -d 3 # Limit depth to 3
agent-browser snapshot -s "#main" # Scope to CSS selector
``` ```
### Interactions (use @refs from snapshot) ### Interactions (use @refs from snapshot)
```bash ```bash
agent-browser click @e1 # Click agent-browser click @e1 # Click
agent-browser dblclick @e1 # Double-click agent-browser dblclick @e1 # Double-click
agent-browser focus @e1 # Focus element
agent-browser fill @e2 "text" # Clear and type agent-browser fill @e2 "text" # Clear and type
agent-browser type @e2 "text" # Type without clearing agent-browser type @e2 "text" # Type without clearing
agent-browser press Enter # Press key agent-browser press Enter # Press key
agent-browser press Control+a # Key combination 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 hover @e1 # Hover
agent-browser check @e1 # Check checkbox agent-browser check @e1 # Check checkbox
agent-browser uncheck @e1 # Uncheck checkbox agent-browser uncheck @e1 # Uncheck checkbox
agent-browser select @e1 "value" # Select dropdown agent-browser select @e1 "value" # Select dropdown
agent-browser scroll down 500 # Scroll page agent-browser scroll down 500 # Scroll page
agent-browser scrollintoview @e1 # Scroll element into view 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 ### Get information
```bash ```bash
agent-browser get text @e1 # Get element text 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 value @e1 # Get input value
agent-browser get attr @e1 href # Get attribute
agent-browser get title # Get page title agent-browser get title # Get page title
agent-browser get url # Get current URL 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 ```bash
agent-browser screenshot # Screenshot to stdout agent-browser screenshot # Screenshot to stdout
agent-browser screenshot path.png # Save to file agent-browser screenshot path.png # Save to file
agent-browser screenshot --full # Full page 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 ### Wait
```bash ```bash
agent-browser wait @e1 # Wait for element agent-browser wait @e1 # Wait for element
agent-browser wait 2000 # Wait milliseconds agent-browser wait 2000 # Wait milliseconds
agent-browser wait --text "Success" # Wait for text 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 --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) ### 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 role button click --name "Submit"
agent-browser find text "Sign In" click agent-browser find text "Sign In" click
agent-browser find label "Email" fill "user@test.com" 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 ## Example: Form submission
@@ -137,7 +235,18 @@ agent-browser get text @e1 --json
## Debugging ## Debugging
```bash ```bash
agent-browser open example.com --headed # Show browser window
agent-browser console # View console messages
agent-browser errors # View page errors
agent-browser record start ./debug.webm # Record from current page
agent-browser record stop # Save recording
agent-browser open example.com --headed # Show browser window agent-browser open example.com --headed # Show browser window
agent-browser --cdp 9222 snapshot # Connect via CDP
agent-browser console # View console messages agent-browser console # View console messages
agent-browser console --clear # Clear console
agent-browser errors # View page errors 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, SelectCommand,
HoverCommand, HoverCommand,
ContentCommand, ContentCommand,
TabNewCommand,
TabSwitchCommand, TabSwitchCommand,
TabCloseCommand, TabCloseCommand,
WindowNewCommand, WindowNewCommand,
@@ -49,6 +50,7 @@ import type {
IsCheckedCommand, IsCheckedCommand,
CountCommand, CountCommand,
BoundingBoxCommand, BoundingBoxCommand,
StylesCommand,
TraceStartCommand, TraceStartCommand,
TraceStopCommand, TraceStopCommand,
HarStopCommand, HarStopCommand,
@@ -99,6 +101,9 @@ import type {
InputMouseCommand, InputMouseCommand,
InputKeyboardCommand, InputKeyboardCommand,
InputTouchCommand, InputTouchCommand,
RecordingStartCommand,
RecordingStopCommand,
RecordingRestartCommand,
NavigateData, NavigateData,
ScreenshotData, ScreenshotData,
EvaluateData, EvaluateData,
@@ -109,7 +114,11 @@ import type {
TabCloseData, TabCloseData,
ScreencastStartData, ScreencastStartData,
ScreencastStopData, ScreencastStopData,
RecordingStartData,
RecordingStopData,
RecordingRestartData,
InputEventData, InputEventData,
StylesData,
} from './types.js'; } from './types.js';
import { successResponse, errorResponse } from './protocol.js'; import { successResponse, errorResponse } from './protocol.js';
@@ -311,6 +320,8 @@ export async function executeCommand(command: Command, browser: BrowserManager):
return await handleCount(command, browser); return await handleCount(command, browser);
case 'boundingbox': case 'boundingbox':
return await handleBoundingBox(command, browser); return await handleBoundingBox(command, browser);
case 'styles':
return await handleStyles(command, browser);
case 'video_start': case 'video_start':
return await handleVideoStart(command, browser); return await handleVideoStart(command, browser);
case 'video_stop': case 'video_stop':
@@ -427,6 +438,12 @@ export async function executeCommand(command: Command, browser: BrowserManager):
return await handleInputKeyboard(command, browser); return await handleInputKeyboard(command, browser);
case 'input_touch': case 'input_touch':
return await handleInputTouch(command, browser); 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: { default: {
// TypeScript narrows to never here, but we handle it for safety // TypeScript narrows to never here, but we handle it for safety
const unknownCommand = command as { id: string; action: string }; const unknownCommand = command as { id: string; action: string };
@@ -697,10 +714,17 @@ async function handleClose(
} }
async function handleTabNew( async function handleTabNew(
command: Command & { action: 'tab_new' }, command: TabNewCommand,
browser: BrowserManager browser: BrowserManager
): Promise<Response<TabNewData>> { ): Promise<Response<TabNewData>> {
const result = await browser.newTab(); 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); return successResponse(command.id, result);
} }
@@ -1226,6 +1250,62 @@ async function handleBoundingBox(
return successResponse(command.id, { box }); 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 // Advanced handlers
async function handleVideoStart( async function handleVideoStart(
@@ -1424,8 +1504,8 @@ async function handleInputValue(
command: InputValueCommand, command: InputValueCommand,
browser: BrowserManager browser: BrowserManager
): Promise<Response> { ): Promise<Response> {
const page = browser.getPage(); const locator = browser.getLocator(command.selector);
const value = await page.locator(command.selector).inputValue(); const value = await locator.inputValue();
return successResponse(command.id, { value }); return successResponse(command.id, { value });
} }
@@ -1886,3 +1966,37 @@ async function handleInputTouch(
}); });
return successResponse(command.id, { injected: true }); 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 { BrowserManager } from './browser.js';
import { chromium } from 'playwright-core';
describe('BrowserManager', () => { describe('BrowserManager', () => {
let browser: BrowserManager; let browser: BrowserManager;
@@ -390,6 +391,35 @@ describe('BrowserManager', () => {
const cdp2 = await browser.getCDPSession(); const cdp2 = await browser.getCDPSession();
expect(cdp1).toBe(cdp2); 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', () => { describe('screencast', () => {
@@ -405,7 +435,7 @@ describe('BrowserManager', () => {
expect(browser.isScreencasting()).toBe(true); expect(browser.isScreencasting()).toBe(true);
// Wait a bit for at least one frame // 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(); await browser.stopScreencast();
expect(browser.isScreencasting()).toBe(false); expect(browser.isScreencasting()).toBe(false);
+245 -4
View File
@@ -12,9 +12,11 @@ import {
type Route, type Route,
type Locator, type Locator,
type CDPSession, type CDPSession,
type Video,
} from 'playwright-core'; } from 'playwright-core';
import path from 'node:path'; import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
import { existsSync, mkdirSync, rmSync } from 'node:fs';
import type { LaunchCommand } from './types.js'; import type { LaunchCommand } from './types.js';
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.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 frameCallback: ((frame: ScreencastFrame) => void) | null = null;
private screencastFrameHandler: ((params: any) => 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 * Check if browser is launched
*/ */
@@ -682,6 +690,7 @@ export class BrowserManager {
args: [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`], args: [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`],
viewport, viewport,
extraHTTPHeaders: options.headers, extraHTTPHeaders: options.headers,
...(options.proxy && { proxy: options.proxy }),
} }
); );
this.isPersistentContext = true; this.isPersistentContext = true;
@@ -691,10 +700,14 @@ export class BrowserManager {
executablePath: options.executablePath, executablePath: options.executablePath,
}); });
this.cdpPort = null; 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); this.contexts.push(context);
const page = context.pages()[0] ?? (await context.newPage()); 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.'); 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) { if (allPages.length === 0) {
throw new Error('No page found. Make sure the app has loaded content.'); 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({ const context = await this.browser.newContext({
viewport: viewport ?? { width: 1280, height: 720 }, viewport: viewport ?? { width: 1280, height: 720 },
}); });
context.setDefaultTimeout(10000); context.setDefaultTimeout(60000);
this.contexts.push(context); this.contexts.push(context);
const page = await context.newPage(); 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 * Close the browser and clean up
*/ */
async close(): Promise<void> { async close(): Promise<void> {
// Stop recording if active (saves video)
if (this.recordingContext) {
await this.stopRecording();
}
// Stop screencast if active // Stop screencast if active
if (this.screencastActive) { if (this.screencastActive) {
await this.stopScreencast(); await this.stopScreencast();
+1 -1
View File
@@ -200,7 +200,7 @@ export async function startDaemon(options?: { streamPort?: number }): Promise<vo
await browser.launch({ await browser.launch({
id: 'auto', id: 'auto',
action: 'launch', action: 'launch',
headless: true, headless: process.env.AGENT_BROWSER_HEADED !== '1',
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH, executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
extensions: extensions, 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', () => { it('should reject navigate without url', () => {
const result = parseCommand(cmd({ id: '1', action: 'navigate' })); const result = parseCommand(cmd({ id: '1', action: 'navigate' }));
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -373,6 +389,14 @@ describe('parseCommand', () => {
expect(result.success).toBe(true); 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', () => { it('should parse tab_list', () => {
const result = parseCommand(cmd({ id: '1', action: 'tab_list' })); const result = parseCommand(cmd({ id: '1', action: 'tab_list' }));
expect(result.success).toBe(true); expect(result.success).toBe(true);
+40 -1
View File
@@ -19,12 +19,24 @@ const launchSchema = baseCommandSchema.extend({
.optional(), .optional(),
browser: z.enum(['chromium', 'firefox', 'webkit']).optional(), browser: z.enum(['chromium', 'firefox', 'webkit']).optional(),
cdpPort: z.number().positive().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({ const navigateSchema = baseCommandSchema.extend({
action: z.literal('navigate'), action: z.literal('navigate'),
url: z.string().min(1), url: z.string().min(1),
waitUntil: z.enum(['load', 'domcontentloaded', 'networkidle']).optional(), waitUntil: z.enum(['load', 'domcontentloaded', 'networkidle']).optional(),
headers: z.record(z.string()).optional(),
}); });
const clickSchema = baseCommandSchema.extend({ const clickSchema = baseCommandSchema.extend({
@@ -295,6 +307,11 @@ const boundingBoxSchema = baseCommandSchema.extend({
selector: z.string().min(1), selector: z.string().min(1),
}); });
const stylesSchema = baseCommandSchema.extend({
action: z.literal('styles'),
selector: z.string().min(1),
});
const videoStartSchema = baseCommandSchema.extend({ const videoStartSchema = baseCommandSchema.extend({
action: z.literal('video_start'), action: z.literal('video_start'),
path: z.string().min(1), path: z.string().min(1),
@@ -304,6 +321,23 @@ const videoStopSchema = baseCommandSchema.extend({
action: z.literal('video_stop'), 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({ const traceStartSchema = baseCommandSchema.extend({
action: z.literal('trace_start'), action: z.literal('trace_start'),
screenshots: z.boolean().optional(), screenshots: z.boolean().optional(),
@@ -642,7 +676,7 @@ const pressSchema = baseCommandSchema.extend({
const screenshotSchema = baseCommandSchema.extend({ const screenshotSchema = baseCommandSchema.extend({
action: z.literal('screenshot'), action: z.literal('screenshot'),
path: z.string().optional(), path: z.string().nullable().optional(),
fullPage: z.boolean().optional(), fullPage: z.boolean().optional(),
selector: z.string().min(1).optional(), selector: z.string().min(1).optional(),
format: z.enum(['png', 'jpeg']).optional(), format: z.enum(['png', 'jpeg']).optional(),
@@ -702,6 +736,7 @@ const closeSchema = baseCommandSchema.extend({
// Tab/Window schemas // Tab/Window schemas
const tabNewSchema = baseCommandSchema.extend({ const tabNewSchema = baseCommandSchema.extend({
action: z.literal('tab_new'), action: z.literal('tab_new'),
url: z.string().min(1).optional(),
}); });
const tabListSchema = baseCommandSchema.extend({ const tabListSchema = baseCommandSchema.extend({
@@ -791,8 +826,12 @@ const commandSchema = z.discriminatedUnion('action', [
isCheckedSchema, isCheckedSchema,
countSchema, countSchema,
boundingBoxSchema, boundingBoxSchema,
stylesSchema,
videoStartSchema, videoStartSchema,
videoStopSchema, videoStopSchema,
recordingStartSchema,
recordingStopSchema,
recordingRestartSchema,
traceStartSchema, traceStartSchema,
traceStopSchema, traceStopSchema,
harStartSchema, harStartSchema,
+75 -1
View File
@@ -16,6 +16,12 @@ export interface LaunchCommand extends BaseCommand {
executablePath?: string; executablePath?: string;
cdpPort?: number; cdpPort?: number;
extensions?: string[]; extensions?: string[];
proxy?: {
server: string;
bypass?: string;
username?: string;
password?: string;
};
} }
export interface NavigateCommand extends BaseCommand { export interface NavigateCommand extends BaseCommand {
@@ -310,6 +316,12 @@ export interface BoundingBoxCommand extends BaseCommand {
selector: string; selector: string;
} }
// Computed styles
export interface StylesCommand extends BaseCommand {
action: 'styles';
selector: string;
}
// More semantic locators // More semantic locators
export interface GetByAltTextCommand extends BaseCommand { export interface GetByAltTextCommand extends BaseCommand {
action: 'getbyalttext'; action: 'getbyalttext';
@@ -502,7 +514,7 @@ export interface InputTouchCommand extends BaseCommand {
modifiers?: number; modifiers?: number;
} }
// Video recording // Video recording (Playwright native - requires launch-time setup)
export interface VideoStartCommand extends BaseCommand { export interface VideoStartCommand extends BaseCommand {
action: 'video_start'; action: 'video_start';
path: string; path: string;
@@ -512,6 +524,23 @@ export interface VideoStopCommand extends BaseCommand {
action: 'video_stop'; 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 // Tracing
export interface TraceStartCommand extends BaseCommand { export interface TraceStartCommand extends BaseCommand {
action: 'trace_start'; action: 'trace_start';
@@ -749,6 +778,7 @@ export interface CloseCommand extends BaseCommand {
// Tab/Window commands // Tab/Window commands
export interface TabNewCommand extends BaseCommand { export interface TabNewCommand extends BaseCommand {
action: 'tab_new'; action: 'tab_new';
url?: string;
} }
export interface TabListCommand extends BaseCommand { export interface TabListCommand extends BaseCommand {
@@ -833,8 +863,12 @@ export type Command =
| IsCheckedCommand | IsCheckedCommand
| CountCommand | CountCommand
| BoundingBoxCommand | BoundingBoxCommand
| StylesCommand
| VideoStartCommand | VideoStartCommand
| VideoStopCommand | VideoStopCommand
| RecordingStartCommand
| RecordingStopCommand
| RecordingRestartCommand
| TraceStartCommand | TraceStartCommand
| TraceStopCommand | TraceStopCommand
| HarStartCommand | HarStartCommand
@@ -968,10 +1002,50 @@ export interface ScreencastStopData {
stopped: boolean; 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 { export interface InputEventData {
injected: boolean; 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 // Browser state
export interface BrowserState { export interface BrowserState {
browser: Browser | null; browser: Browser | null;