Compare commits

..
Author SHA1 Message Date
Chris Tate 2322e5b639 address feedback 2026-01-12 01:19:14 -06:00
Chris Tate 36ad3ac66e fix wait 2026-01-12 01:13:56 -06:00
Chris Tate 3739ebca2d fix inaccuracies 2026-01-12 01:10:59 -06:00
52 changed files with 151 additions and 11043 deletions
-19
View File
@@ -1,19 +0,0 @@
{
"$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"
}
]
}
-103
View File
@@ -83,106 +83,3 @@ jobs:
- name: Build release binary
run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
- name: Run Rust tests
run: cargo test --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
windows-integration:
name: Windows Integration Test
runs-on: windows-latest
needs: rust
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Cache Cargo dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
cli/target/
key: windows-cargo-x86_64-pc-windows-msvc-${{ hashFiles('cli/Cargo.lock') }}
restore-keys: |
windows-cargo-x86_64-pc-windows-msvc-
- name: Build Rust CLI
run: cargo build --release --manifest-path cli/Cargo.toml --target x86_64-pc-windows-msvc
- name: Install npm dependencies
run: pnpm install
- name: Build TypeScript
run: pnpm build
- name: Copy CLI binary to bin directory
run: |
Copy-Item cli/target/x86_64-pc-windows-msvc/release/agent-browser.exe bin/agent-browser-win32-x64.exe
- name: Test agent-browser install command
run: |
$env:PATH = "$pwd\bin;$env:PATH"
bin/agent-browser-win32-x64.exe install
shell: pwsh
- name: Verify Chromium was installed
run: |
$playwrightPath = "$env:LOCALAPPDATA\ms-playwright"
if (Test-Path $playwrightPath) {
Write-Host "Playwright browsers installed at: $playwrightPath"
Get-ChildItem $playwrightPath -Recurse -Depth 2 | Select-Object -First 20
} else {
Write-Error "Playwright browsers not found!"
exit 1
}
shell: pwsh
serverless-chromium:
name: Serverless Chromium (@sparticuz/chromium)
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Install @sparticuz/chromium
run: pnpm add -D @sparticuz/chromium
- name: Build TypeScript
run: pnpm build
- name: Run serverless integration test
run: pnpm exec vitest run test/serverless.test.ts
-6
View File
@@ -42,9 +42,3 @@ yarn.lock
# opensrc - source code for packages
opensrc/
# Docs site
docs/node_modules/
docs/.next/
docs/out/
docs/package-lock.json
-1
View File
@@ -5,7 +5,6 @@ Instructions for AI coding agents working with this codebase.
## Code Style
- Do not use emojis in code, output, or documentation. Unicode symbols (✓, ✗, →, ⚠) are acceptable.
- CLI colored output uses `cli/src/color.rs`. This module respects the `NO_COLOR` environment variable. Never use hardcoded ANSI color codes.
<!-- opensrc:start -->
+1 -205
View File
@@ -18,8 +18,6 @@ git clone https://github.com/vercel-labs/agent-browser
cd agent-browser
pnpm install
pnpm build
pnpm build:native # Requires Rust (https://rustup.rs)
pnpm link --global # Makes agent-browser available globally
agent-browser install
```
@@ -74,11 +72,10 @@ agent-browser scroll <dir> [px] # Scroll (up/down/left/right)
agent-browser scrollintoview <sel> # Scroll element into view (alias: scrollinto)
agent-browser drag <src> <tgt> # Drag and drop
agent-browser upload <sel> <files> # Upload files
agent-browser screenshot [path] # Take screenshot (--full for full page, base64 png to stdout if no path)
agent-browser screenshot [path] # Take screenshot (--full for full page)
agent-browser pdf <path> # Save as PDF
agent-browser snapshot # Accessibility tree with refs (best for AI)
agent-browser eval <js> # Run JavaScript
agent-browser connect <port> # Connect to browser via CDP
agent-browser close # Close browser (aliases: quit, exit)
```
@@ -296,14 +293,11 @@ agent-browser snapshot -i -c -d 5 # Combine options
| Option | Description |
|--------|-------------|
| `--session <name>` | Use isolated session (or `AGENT_BROWSER_SESSION` env) |
| `--headers <json>` | Set HTTP headers scoped to the URL's origin |
| `--executable-path <path>` | Custom browser executable (or `AGENT_BROWSER_EXECUTABLE_PATH` env) |
| `--json` | JSON output (for agents) |
| `--full, -f` | Full page screenshot |
| `--name, -n` | Locator name filter |
| `--exact` | Exact text match |
| `--headed` | Show browser window (not headless) |
| `--cdp <port>` | Connect via Chrome DevTools Protocol |
| `--debug` | Debug output |
## Selectors
@@ -393,204 +387,6 @@ agent-browser open example.com --headed
This opens a visible browser window instead of running headless.
## Authenticated Sessions
Use `--headers` to set HTTP headers for a specific origin, enabling authentication without login flows:
```bash
# Headers are scoped to api.example.com only
agent-browser open api.example.com --headers '{"Authorization": "Bearer <token>"}'
# Requests to api.example.com include the auth header
agent-browser snapshot -i --json
agent-browser click @e2
# Navigate to another domain - headers are NOT sent (safe!)
agent-browser open other-site.com
```
This is useful for:
- **Skipping login flows** - Authenticate via headers instead of UI
- **Switching users** - Start new sessions with different auth tokens
- **API testing** - Access protected endpoints directly
- **Security** - Headers are scoped to the origin, not leaked to other domains
To set headers for multiple origins, use `--headers` with each `open` command:
```bash
agent-browser open api.example.com --headers '{"Authorization": "Bearer token1"}'
agent-browser open api.acme.com --headers '{"Authorization": "Bearer token2"}'
```
For global headers (all domains), use `set headers`:
```bash
agent-browser set headers '{"X-Custom-Header": "value"}'
```
## Custom Browser Executable
Use a custom browser executable instead of the bundled Chromium. This is useful for:
- **Serverless deployment**: Use lightweight Chromium builds like `@sparticuz/chromium` (~50MB vs ~684MB)
- **System browsers**: Use an existing Chrome/Chromium installation
- **Custom builds**: Use modified browser builds
### CLI Usage
```bash
# Via flag
agent-browser --executable-path /path/to/chromium open example.com
# Via environment variable
AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chromium agent-browser open example.com
```
### Serverless Example (Vercel/AWS Lambda)
```typescript
import chromium from '@sparticuz/chromium';
import { BrowserManager } from 'agent-browser';
export async function handler() {
const browser = new BrowserManager();
await browser.launch({
executablePath: await chromium.executablePath(),
headless: true,
});
// ... use browser
}
```
## CDP Mode
Connect to an existing browser via Chrome DevTools Protocol:
```bash
# Start Chrome with: google-chrome --remote-debugging-port=9222
# Connect once, then run commands without --cdp
agent-browser connect 9222
agent-browser snapshot
agent-browser tab
agent-browser close
# Or pass --cdp on each command
agent-browser --cdp 9222 snapshot
```
This enables control of:
- Electron apps
- Chrome/Chromium instances with remote debugging
- WebView2 applications
- Any browser exposing a CDP endpoint
## Streaming (Browser Preview)
Stream the browser viewport via WebSocket for live preview or "pair browsing" where a human can watch and interact alongside an AI agent.
### Enable Streaming
Set the `AGENT_BROWSER_STREAM_PORT` environment variable:
```bash
AGENT_BROWSER_STREAM_PORT=9223 agent-browser open example.com
```
This starts a WebSocket server on the specified port that streams the browser viewport and accepts input events.
### WebSocket Protocol
Connect to `ws://localhost:9223` to receive frames and send input:
**Receive frames:**
```json
{
"type": "frame",
"data": "<base64-encoded-jpeg>",
"metadata": {
"deviceWidth": 1280,
"deviceHeight": 720,
"pageScaleFactor": 1,
"offsetTop": 0,
"scrollOffsetX": 0,
"scrollOffsetY": 0
}
}
```
**Send mouse events:**
```json
{
"type": "input_mouse",
"eventType": "mousePressed",
"x": 100,
"y": 200,
"button": "left",
"clickCount": 1
}
```
**Send keyboard events:**
```json
{
"type": "input_keyboard",
"eventType": "keyDown",
"key": "Enter",
"code": "Enter"
}
```
**Send touch events:**
```json
{
"type": "input_touch",
"eventType": "touchStart",
"touchPoints": [{ "x": 100, "y": 200 }]
}
```
### Programmatic API
For advanced use, control streaming directly via the protocol:
```typescript
import { BrowserManager } from 'agent-browser';
const browser = new BrowserManager();
await browser.launch({ headless: true });
await browser.navigate('https://example.com');
// Start screencast
await browser.startScreencast((frame) => {
// frame.data is base64-encoded image
// frame.metadata contains viewport info
console.log('Frame received:', frame.metadata.deviceWidth, 'x', frame.metadata.deviceHeight);
}, {
format: 'jpeg',
quality: 80,
maxWidth: 1280,
maxHeight: 720,
});
// Inject mouse events
await browser.injectMouseEvent({
type: 'mousePressed',
x: 100,
y: 200,
button: 'left',
});
// Inject keyboard events
await browser.injectKeyboardEvent({
type: 'keyDown',
key: 'Enter',
code: 'Enter',
});
// Stop when done
await browser.stopScreencast();
```
## Architecture
agent-browser uses a client-daemon architecture:
+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "agent-browser"
version = "0.5.0"
version = "0.4.2"
dependencies = [
"libc",
"serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-browser"
version = "0.5.0"
version = "0.4.2"
edition = "2021"
description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0"
-158
View File
@@ -1,158 +0,0 @@
//! 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('⚠'));
}
}
+28 -437
View File
@@ -75,24 +75,12 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
context: cmd.to_string(),
usage: "open <url>",
})?;
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:") {
let url = if url.starts_with("http") {
url.to_string()
} else {
format!("https://{}", url)
};
let mut nav_cmd = json!({ "id": id, "action": "navigate", "url": url });
// If --headers flag is set, include headers (scoped to this origin)
if let Some(ref headers_json) = flags.headers {
if let Ok(headers) = serde_json::from_str::<serde_json::Value>(headers_json) {
nav_cmd["headers"] = headers;
}
}
Ok(nav_cmd)
Ok(json!({ "id": id, "action": "navigate", "url": url }))
}
"back" => Ok(json!({ "id": id, "action": "back" })),
"forward" => Ok(json!({ "id": id, "action": "forward" })),
@@ -158,18 +146,13 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
"select" => {
let sel = rest.get(0).ok_or_else(|| ParseError::MissingArguments {
context: "select".to_string(),
usage: "select <selector> <value...>",
usage: "select <selector> <value>",
})?;
let _val = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
let val = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "select".to_string(),
usage: "select <selector> <value...>",
usage: "select <selector> <value>",
})?;
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 }))
}
Ok(json!({ "id": id, "action": "select", "selector": sel, "value": val }))
}
"drag" => {
let src = rest.get(0).ok_or_else(|| ParseError::MissingArguments {
@@ -283,11 +266,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === Screenshot/PDF ===
"screenshot" => {
let mut cmd = json!({ "id": id, "action": "screenshot", "fullPage": flags.full });
if let Some(path) = rest.get(0) {
cmd["path"] = json!(path);
}
Ok(cmd)
Ok(json!({ "id": id, "action": "screenshot", "path": rest.get(0), "fullPage": flags.full }))
}
"pdf" => {
let path = rest.get(0).ok_or_else(|| ParseError::MissingArguments {
@@ -337,19 +316,6 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === Close ===
"close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "close" })),
// === Connect (CDP) ===
"connect" => {
let port_str = rest.get(0).ok_or_else(|| ParseError::MissingArguments {
context: "connect".to_string(),
usage: "connect <port>",
})?;
let port: u16 = port_str.parse().map_err(|_| ParseError::MissingArguments {
context: format!("connect: invalid port '{}'", port_str),
usage: "connect <port>",
})?;
Ok(json!({ "id": id, "action": "launch", "cdpPort": port }))
}
// === Get ===
"get" => parse_get(&rest, &id),
@@ -394,20 +360,10 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === Tabs ===
"tab" => {
match rest.get(0).map(|s| *s) {
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("new") => Ok(json!({ "id": id, "action": "tab_new", "url": rest.get(1) })),
Some("list") => Ok(json!({ "id": id, "action": "tab_list" })),
Some("close") => {
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)
Ok(json!({ "id": id, "action": "tab_close", "index": rest.get(1).and_then(|s| s.parse::<i32>().ok()) }))
}
Some(n) if n.parse::<i32>().is_ok() => {
Ok(json!({ "id": id, "action": "tab_switch", "index": n.parse::<i32>().unwrap() }))
@@ -435,7 +391,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === Frame ===
"frame" => {
if rest.get(0).map(|s| *s) == Some("main") {
Ok(json!({ "id": id, "action": "mainframe" }))
Ok(json!({ "id": id, "action": "frame_main" }))
} else {
let sel = rest.get(0).ok_or_else(|| ParseError::MissingArguments {
context: "frame".to_string(),
@@ -450,11 +406,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
const VALID: &[&str] = &["accept", "dismiss"];
match rest.get(0).map(|s| *s) {
Some("accept") => {
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)
Ok(json!({ "id": id, "action": "dialog", "response": "accept", "promptText": rest.get(1) }))
}
Some("dismiss") => Ok(json!({ "id": id, "action": "dialog", "response": "dismiss" })),
Some(sub) => Err(ParseError::UnknownSubcommand {
@@ -472,14 +424,8 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
"trace" => {
const VALID: &[&str] = &["start", "stop"];
match rest.get(0).map(|s| *s) {
Some("start") => Ok(json!({ "id": id, "action": "trace_start" })),
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("start") => Ok(json!({ "id": id, "action": "trace_start", "path": rest.get(1) })),
Some("stop") => Ok(json!({ "id": id, "action": "trace_stop", "path": rest.get(1) })),
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
valid_options: VALID,
@@ -490,60 +436,6 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}),
}
}
// === Recording (Playwright native video recording) ===
"record" => {
const VALID: &[&str] = &["start", "stop", "restart"];
match rest.get(0).map(|s| *s) {
Some("start") => {
let path = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "record start".to_string(),
usage: "record start <output.webm> [url]",
})?;
// Optional URL parameter
let url = rest.get(2);
let mut cmd = json!({ "id": id, "action": "recording_start", "path": path });
if let Some(u) = url {
// Add https:// prefix if needed
let url_str = if u.starts_with("http") {
u.to_string()
} else {
format!("https://{}", u)
};
cmd["url"] = json!(url_str);
}
Ok(cmd)
}
Some("stop") => Ok(json!({ "id": id, "action": "recording_stop" })),
Some("restart") => {
let path = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "record restart".to_string(),
usage: "record restart <output.webm> [url]",
})?;
// Optional URL parameter
let url = rest.get(2);
let mut cmd = json!({ "id": id, "action": "recording_restart", "path": path });
if let Some(u) = url {
// Add https:// prefix if needed
let url_str = if u.starts_with("http") {
u.to_string()
} else {
format!("https://{}", u)
};
cmd["url"] = json!(url_str);
}
Ok(cmd)
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
valid_options: VALID,
}),
None => Err(ParseError::MissingArguments {
context: "record".to_string(),
usage: "record <start|stop|restart> [path] [url]",
}),
}
}
"console" => {
let clear = rest.iter().any(|&s| s == "--clear");
Ok(json!({ "id": id, "action": "console", "clear": clear }))
@@ -596,7 +488,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}
fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
const VALID: &[&str] = &["text", "html", "value", "attr", "url", "title", "count", "box", "styles"];
const VALID: &[&str] = &["text", "html", "value", "attr", "url", "title", "count", "box"];
match rest.get(0).map(|s| *s) {
Some("text") => {
@@ -647,20 +539,13 @@ fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
})?;
Ok(json!({ "id": id, "action": "boundingbox", "selector": sel }))
}
Some("styles") => {
let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "get styles".to_string(),
usage: "get styles <selector>",
})?;
Ok(json!({ "id": id, "action": "styles", "selector": sel }))
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
valid_options: VALID,
}),
None => Err(ParseError::MissingArguments {
context: "get".to_string(),
usage: "get <text|html|value|attr|url|title|count|box|styles> [args...]",
usage: "get <text|html|value|attr|url|title|count|box> [args...]",
}),
}
}
@@ -738,39 +623,15 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
};
match *locator {
"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)
}
"role" => Ok(json!({ "id": id, "action": "getbyrole", "role": value, "subaction": subaction, "value": fill_value, "name": name, "exact": exact })),
"text" => Ok(json!({ "id": id, "action": "getbytext", "text": value, "subaction": subaction, "exact": exact })),
"label" => {
let mut cmd = json!({ "id": id, "action": "getbylabel", "label": value, "subaction": subaction, "exact": exact });
if let Some(v) = fill_value { cmd["value"] = json!(v); }
Ok(cmd)
}
"placeholder" => {
let mut cmd = json!({ "id": id, "action": "getbyplaceholder", "placeholder": value, "subaction": subaction, "exact": exact });
if let Some(v) = fill_value { cmd["value"] = json!(v); }
Ok(cmd)
}
"label" => Ok(json!({ "id": id, "action": "getbylabel", "label": value, "subaction": subaction, "value": fill_value, "exact": exact })),
"placeholder" => Ok(json!({ "id": id, "action": "getbyplaceholder", "placeholder": value, "subaction": subaction, "value": fill_value, "exact": exact })),
"alt" => Ok(json!({ "id": id, "action": "getbyalttext", "text": value, "subaction": subaction, "exact": exact })),
"title" => Ok(json!({ "id": id, "action": "getbytitle", "text": value, "subaction": subaction, "exact": exact })),
"testid" => {
let mut cmd = json!({ "id": id, "action": "getbytestid", "testId": value, "subaction": subaction });
if let Some(v) = fill_value { cmd["value"] = json!(v); }
Ok(cmd)
}
"first" => {
let mut cmd = json!({ "id": id, "action": "nth", "selector": value, "index": 0, "subaction": subaction });
if let Some(v) = fill_value { cmd["value"] = json!(v); }
Ok(cmd)
}
"last" => {
let mut cmd = json!({ "id": id, "action": "nth", "selector": value, "index": -1, "subaction": subaction });
if let Some(v) = fill_value { cmd["value"] = json!(v); }
Ok(cmd)
}
"testid" => Ok(json!({ "id": id, "action": "getbytestid", "testId": value, "subaction": subaction, "value": fill_value })),
"first" => Ok(json!({ "id": id, "action": "nth", "selector": value, "index": 0, "subaction": subaction, "value": fill_value })),
"last" => Ok(json!({ "id": id, "action": "nth", "selector": value, "index": -1, "subaction": subaction, "value": fill_value })),
_ => unreachable!(),
}
}
@@ -793,9 +654,7 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
} else {
None
};
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)
Ok(json!({ "id": id, "action": "nth", "selector": sel, "index": idx, "subaction": sub, "value": fv }))
}
_ => Err(ParseError::UnknownSubcommand {
subcommand: locator.to_string(),
@@ -836,7 +695,7 @@ fn parse_mouse(rest: &[&str], id: &str) -> Result<Value, ParseError> {
Some("wheel") => {
let dy = rest.get(1).and_then(|s| s.parse::<i32>().ok()).unwrap_or(100);
let dx = rest.get(2).and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
Ok(json!({ "id": id, "action": "wheel", "deltaX": dx, "deltaY": dy }))
Ok(json!({ "id": id, "action": "mousewheel", "deltaX": dx, "deltaY": dy }))
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
@@ -907,13 +766,7 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
context: "set headers".to_string(),
usage: "set headers <json>",
})?;
// Parse the JSON string into an object
let headers: serde_json::Value = serde_json::from_str(headers_json)
.map_err(|_| ParseError::MissingArguments {
context: "set headers".to_string(),
usage: "set headers <json> (must be valid JSON object)",
})?;
Ok(json!({ "id": id, "action": "headers", "headers": headers }))
Ok(json!({ "id": id, "action": "headers", "headers": headers_json }))
}
Some("credentials") | Some("auth") => {
let user = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
@@ -934,12 +787,8 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
} else {
"no-preference"
};
let reduced = if rest.iter().any(|&s| s == "reduced-motion") {
"reduce"
} else {
"no-preference"
};
Ok(json!({ "id": id, "action": "emulatemedia", "colorScheme": color, "reducedMotion": reduced }))
let reduced = rest.iter().any(|&s| s == "reduced-motion");
Ok(json!({ "id": id, "action": "media", "colorScheme": color, "reducedMotion": reduced }))
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
@@ -966,22 +815,12 @@ fn parse_network(rest: &[&str], id: &str) -> Result<Value, ParseError> {
let body = body_idx.and_then(|i| rest.get(i + 1).map(|s| *s));
Ok(json!({ "id": id, "action": "route", "url": url, "abort": abort, "body": body }))
}
Some("unroute") => {
let mut cmd = json!({ "id": id, "action": "unroute" });
if let Some(url) = rest.get(1) {
cmd["url"] = json!(url);
}
Ok(cmd)
},
Some("unroute") => Ok(json!({ "id": id, "action": "unroute", "url": rest.get(1) })),
Some("requests") => {
let clear = rest.iter().any(|&s| s == "--clear");
let filter_idx = rest.iter().position(|&s| s == "--filter");
let filter = filter_idx.and_then(|i| rest.get(i + 1).map(|s| *s));
let mut cmd = json!({ "id": id, "action": "requests", "clear": clear });
if let Some(f) = filter {
cmd["filter"] = json!(f);
}
Ok(cmd)
Ok(json!({ "id": id, "action": "requests", "clear": clear, "filter": filter }))
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
@@ -1047,11 +886,6 @@ mod tests {
full: false,
headed: false,
debug: false,
headers: None,
executable_path: None,
extensions: Vec::new(),
cdp: None,
proxy: None,
}
}
@@ -1178,81 +1012,6 @@ mod tests {
assert_eq!(cmd["url"], "https://example.com");
}
#[test]
fn test_navigate_with_headers() {
let mut flags = default_flags();
flags.headers = Some(r#"{"Authorization": "Bearer token"}"#.to_string());
let cmd = parse_command(&args("open api.example.com"), &flags).unwrap();
assert_eq!(cmd["action"], "navigate");
assert_eq!(cmd["url"], "https://api.example.com");
assert_eq!(cmd["headers"]["Authorization"], "Bearer token");
}
#[test]
fn test_navigate_with_multiple_headers() {
let mut flags = default_flags();
flags.headers = Some(r#"{"Authorization": "Bearer token", "X-Custom": "value"}"#.to_string());
let cmd = parse_command(&args("open api.example.com"), &flags).unwrap();
assert_eq!(cmd["headers"]["Authorization"], "Bearer token");
assert_eq!(cmd["headers"]["X-Custom"], "value");
}
#[test]
fn test_navigate_without_headers_flag() {
let cmd = parse_command(&args("open example.com"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "navigate");
// headers should not be present when flag is not set
assert!(cmd.get("headers").is_none());
}
#[test]
fn test_navigate_with_invalid_headers_json() {
let mut flags = default_flags();
flags.headers = Some("not valid json".to_string());
let cmd = parse_command(&args("open api.example.com"), &flags).unwrap();
// Invalid JSON should result in no headers field (graceful handling)
assert!(cmd.get("headers").is_none());
}
// === Set Headers Tests ===
#[test]
fn test_set_headers_parses_json() {
let input: Vec<String> = vec![
"set".to_string(),
"headers".to_string(),
r#"{"Authorization":"Bearer token"}"#.to_string(),
];
let cmd = parse_command(&input, &default_flags()).unwrap();
assert_eq!(cmd["action"], "headers");
// Headers should be an object, not a string
assert!(cmd["headers"].is_object());
assert_eq!(cmd["headers"]["Authorization"], "Bearer token");
}
#[test]
fn test_set_headers_with_multiple_values() {
let input: Vec<String> = vec![
"set".to_string(),
"headers".to_string(),
r#"{"Authorization": "Bearer token", "X-Custom": "value"}"#.to_string(),
];
let cmd = parse_command(&input, &default_flags()).unwrap();
assert_eq!(cmd["headers"]["Authorization"], "Bearer token");
assert_eq!(cmd["headers"]["X-Custom"], "value");
}
#[test]
fn test_set_headers_invalid_json_error() {
let input: Vec<String> = vec![
"set".to_string(),
"headers".to_string(),
"not-valid-json".to_string(),
];
let result = parse_command(&input, &default_flags());
assert!(result.is_err());
}
#[test]
fn test_back() {
let cmd = parse_command(&args("back"), &default_flags()).unwrap();
@@ -1296,46 +1055,12 @@ mod tests {
assert_eq!(cmd["text"], "some text");
}
#[test]
fn test_select() {
let cmd = parse_command(&args("select #menu option1"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "select");
assert_eq!(cmd["selector"], "#menu");
assert_eq!(cmd["values"], "option1");
}
#[test]
fn test_select_multiple_values() {
let cmd = parse_command(
&args("select #menu opt1 opt2 opt3"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "select");
assert_eq!(cmd["selector"], "#menu");
assert_eq!(cmd["values"], json!(["opt1", "opt2", "opt3"]));
}
#[test]
fn test_frame_main() {
let cmd = parse_command(&args("frame main"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "mainframe");
}
// === Tabs ===
#[test]
fn test_tab_new() {
let cmd = parse_command(&args("tab new"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "tab_new");
assert!(cmd.get("url").is_none(), "url should not be present when not provided");
}
#[test]
fn test_tab_new_with_url() {
let cmd = parse_command(&args("tab new https://example.com"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "tab_new");
assert_eq!(cmd["url"], "https://example.com");
}
#[test]
@@ -1363,14 +1088,6 @@ mod tests {
fn test_screenshot() {
let cmd = parse_command(&args("screenshot"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "screenshot");
assert!(cmd.get("path").is_none());
}
#[test]
fn test_screenshot_path() {
let cmd = parse_command(&args("screenshot out.png"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "screenshot");
assert_eq!(cmd["path"], "out.png");
}
#[test]
@@ -1464,82 +1181,6 @@ mod tests {
// === Unknown command ===
// === Record Tests ===
#[test]
fn test_record_start() {
let cmd = parse_command(&args("record start output.webm"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "recording_start");
assert_eq!(cmd["path"], "output.webm");
assert!(cmd.get("url").is_none());
}
#[test]
fn test_record_start_with_url() {
let cmd = parse_command(&args("record start demo.webm https://example.com"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "recording_start");
assert_eq!(cmd["path"], "demo.webm");
assert_eq!(cmd["url"], "https://example.com");
}
#[test]
fn test_record_start_with_url_no_protocol() {
let cmd = parse_command(&args("record start demo.webm example.com"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "recording_start");
assert_eq!(cmd["path"], "demo.webm");
assert_eq!(cmd["url"], "https://example.com");
}
#[test]
fn test_record_start_missing_path() {
let result = parse_command(&args("record start"), &default_flags());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ParseError::MissingArguments { .. }));
}
#[test]
fn test_record_stop() {
let cmd = parse_command(&args("record stop"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "recording_stop");
}
#[test]
fn test_record_restart() {
let cmd = parse_command(&args("record restart output.webm"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "recording_restart");
assert_eq!(cmd["path"], "output.webm");
assert!(cmd.get("url").is_none());
}
#[test]
fn test_record_restart_with_url() {
let cmd = parse_command(&args("record restart demo.webm https://example.com"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "recording_restart");
assert_eq!(cmd["path"], "demo.webm");
assert_eq!(cmd["url"], "https://example.com");
}
#[test]
fn test_record_restart_missing_path() {
let result = parse_command(&args("record restart"), &default_flags());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ParseError::MissingArguments { .. }));
}
#[test]
fn test_record_invalid_subcommand() {
let result = parse_command(&args("record foo"), &default_flags());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ParseError::UnknownSubcommand { .. }));
}
#[test]
fn test_record_missing_subcommand() {
let result = parse_command(&args("record"), &default_flags());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ParseError::MissingArguments { .. }));
}
#[test]
fn test_unknown_command() {
let result = parse_command(&args("unknowncommand"), &default_flags());
@@ -1583,54 +1224,4 @@ mod tests {
assert!(matches!(err, ParseError::MissingArguments { .. }));
assert!(err.format().contains("get text"));
}
// === Protocol alignment tests ===
#[test]
fn test_mouse_wheel() {
let cmd = parse_command(&args("mouse wheel 100 50"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "wheel");
assert_eq!(cmd["deltaY"], 100);
assert_eq!(cmd["deltaX"], 50);
}
#[test]
fn test_set_media() {
let cmd = parse_command(&args("set media dark"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "emulatemedia");
assert_eq!(cmd["colorScheme"], "dark");
assert_eq!(cmd["reducedMotion"], "no-preference");
}
#[test]
fn test_set_media_reduced_motion() {
let cmd = parse_command(&args("set media light reduced-motion"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "emulatemedia");
assert_eq!(cmd["colorScheme"], "light");
assert_eq!(cmd["reducedMotion"], "reduce");
}
#[test]
fn test_find_first_no_value() {
let cmd = parse_command(&args("find first a click"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "nth");
assert_eq!(cmd["index"], 0);
assert!(cmd.get("value").is_none());
}
#[test]
fn test_find_first_with_value() {
let cmd = parse_command(&args("find first input fill hello"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "nth");
assert_eq!(cmd["index"], 0);
assert_eq!(cmd["value"], "hello");
}
#[test]
fn test_find_nth_no_value() {
let cmd = parse_command(&args("find nth 2 a click"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "nth");
assert_eq!(cmd["index"], 2);
assert!(cmd.get("value").is_none());
}
}
+7 -48
View File
@@ -104,9 +104,7 @@ fn get_port_for_session(session: &str) -> u16 {
for c in session.chars() {
hash = ((hash << 5).wrapping_sub(hash)).wrapping_add(c as i32);
}
// 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)
49152 + ((hash.abs() as u16) % 16383)
}
#[cfg(unix)]
@@ -142,8 +140,7 @@ fn is_daemon_running(session: &str) -> bool {
fn daemon_ready(session: &str) -> bool {
#[cfg(unix)]
{
let socket_path = get_socket_path(session);
UnixStream::connect(&socket_path).is_ok()
get_socket_path(session).exists()
}
#[cfg(windows)]
{
@@ -156,44 +153,24 @@ fn daemon_ready(session: &str) -> bool {
}
}
/// Result of ensure_daemon indicating whether a new daemon was started
pub struct DaemonResult {
/// True if we connected to an existing daemon, false if we started a new one
pub already_running: bool,
}
pub fn ensure_daemon(
session: &str,
headed: bool,
executable_path: Option<&str>,
extensions: &[String],
) -> Result<DaemonResult, String> {
pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> {
if is_daemon_running(session) && daemon_ready(session) {
return Ok(DaemonResult {
already_running: true,
});
return Ok(());
}
let exe_path = env::current_exe().map_err(|e| e.to_string())?;
let exe_dir = exe_path.parent().unwrap();
let mut daemon_paths = vec![
let daemon_paths = [
exe_dir.join("daemon.js"),
exe_dir.join("../dist/daemon.js"),
PathBuf::from("dist/daemon.js"),
];
// Check AGENT_BROWSER_HOME environment variable
if let Ok(home) = env::var("AGENT_BROWSER_HOME") {
let home_path = PathBuf::from(&home);
daemon_paths.insert(0, home_path.join("dist/daemon.js"));
daemon_paths.insert(1, home_path.join("daemon.js"));
}
let daemon_path = daemon_paths
.iter()
.find(|p| p.exists())
.ok_or("Daemon not found. Set AGENT_BROWSER_HOME environment variable or run from project directory.")?;
.ok_or("Daemon not found. Run from project directory or ensure daemon.js is alongside binary.")?;
// Spawn daemon as a fully detached background process
#[cfg(unix)]
@@ -209,14 +186,6 @@ pub fn ensure_daemon(
cmd.env("AGENT_BROWSER_HEADED", "1");
}
if let Some(path) = executable_path {
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
}
if !extensions.is_empty() {
cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(","));
}
// Create new process group and session to fully detach
unsafe {
cmd.pre_exec(|| {
@@ -237,8 +206,6 @@ pub fn ensure_daemon(
{
use std::os::windows::process::CommandExt;
// On Windows, call node directly. Command::new handles PATH resolution (node.exe or node.cmd)
// and automatically quotes arguments containing spaces.
let mut cmd = Command::new("node");
cmd.arg(daemon_path)
.env("AGENT_BROWSER_DAEMON", "1")
@@ -248,14 +215,6 @@ pub fn ensure_daemon(
cmd.env("AGENT_BROWSER_HEADED", "1");
}
if let Some(path) = executable_path {
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
}
if !extensions.is_empty() {
cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(","));
}
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
@@ -270,7 +229,7 @@ pub fn ensure_daemon(
for _ in 0..50 {
if daemon_ready(session) {
return Ok(DaemonResult { already_running: false });
return Ok(());
}
thread::sleep(Duration::from_millis(100));
}
+1 -156
View File
@@ -6,30 +6,15 @@ pub struct Flags {
pub headed: bool,
pub debug: bool,
pub session: String,
pub headers: Option<String>,
pub executable_path: Option<String>,
pub cdp: Option<String>,
pub extensions: Vec<String>,
pub proxy: Option<String>,
}
pub fn parse_flags(args: &[String]) -> Flags {
let extensions_env = env::var("AGENT_BROWSER_EXTENSIONS")
.ok()
.map(|s| s.split(',').map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect::<Vec<_>>())
.unwrap_or_default();
let mut flags = Flags {
json: false,
full: false,
headed: false,
debug: false,
session: env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()),
headers: None,
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(),
cdp: None,
extensions: extensions_env,
proxy: None,
};
let mut i = 0;
@@ -45,36 +30,6 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--headers" => {
if let Some(h) = args.get(i + 1) {
flags.headers = Some(h.clone());
i += 1;
}
}
"--executable-path" => {
if let Some(s) = args.get(i + 1) {
flags.executable_path = Some(s.clone());
i += 1;
}
},
"--extension" => {
if let Some(s) = args.get(i + 1) {
flags.extensions.push(s.clone());
i += 1;
}
},
"--cdp" => {
if let Some(s) = args.get(i + 1) {
flags.cdp = Some(s.clone());
i += 1;
}
}
"--proxy" => {
if let Some(p) = args.get(i + 1) {
flags.proxy = Some(p.clone());
i += 1;
}
}
_ => {}
}
i += 1;
@@ -88,15 +43,13 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
// Global flags that should be stripped from command args
const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"];
// Global flags that take a value (need to skip the next arg too)
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers", "--executable-path", "--cdp", "--extension", "--proxy"];
for arg in args.iter() {
if skip_next {
skip_next = false;
continue;
}
if GLOBAL_FLAGS_WITH_VALUE.contains(&arg.as_str()) {
if arg == "--session" {
skip_next = true;
continue;
}
@@ -108,111 +61,3 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn args(s: &str) -> Vec<String> {
s.split_whitespace().map(String::from).collect()
}
#[test]
fn test_parse_headers_flag() {
let flags = parse_flags(&args(r#"open example.com --headers {"Auth":"token"}"#));
assert_eq!(flags.headers, Some(r#"{"Auth":"token"}"#.to_string()));
}
#[test]
fn test_parse_headers_flag_with_spaces() {
// Headers JSON is passed as a single quoted argument in shell
let input: Vec<String> = vec![
"open".to_string(),
"example.com".to_string(),
"--headers".to_string(),
r#"{"Authorization": "Bearer token"}"#.to_string(),
];
let flags = parse_flags(&input);
assert_eq!(flags.headers, Some(r#"{"Authorization": "Bearer token"}"#.to_string()));
}
#[test]
fn test_parse_no_headers_flag() {
let flags = parse_flags(&args("open example.com"));
assert!(flags.headers.is_none());
}
#[test]
fn test_clean_args_removes_headers() {
let input: Vec<String> = vec![
"open".to_string(),
"example.com".to_string(),
"--headers".to_string(),
r#"{"Auth":"token"}"#.to_string(),
];
let clean = clean_args(&input);
assert_eq!(clean, vec!["open", "example.com"]);
}
#[test]
fn test_clean_args_removes_headers_at_start() {
let input: Vec<String> = vec![
"--headers".to_string(),
r#"{"Auth":"token"}"#.to_string(),
"open".to_string(),
"example.com".to_string(),
];
let clean = clean_args(&input);
assert_eq!(clean, vec!["open", "example.com"]);
}
#[test]
fn test_headers_with_other_flags() {
let input: Vec<String> = vec![
"open".to_string(),
"example.com".to_string(),
"--headers".to_string(),
r#"{"Auth":"token"}"#.to_string(),
"--json".to_string(),
"--headed".to_string(),
];
let flags = parse_flags(&input);
assert_eq!(flags.headers, Some(r#"{"Auth":"token"}"#.to_string()));
assert!(flags.json);
assert!(flags.headed);
let clean = clean_args(&input);
assert_eq!(clean, vec!["open", "example.com"]);
}
#[test]
fn test_parse_executable_path_flag() {
let flags = parse_flags(&args("--executable-path /path/to/chromium open example.com"));
assert_eq!(flags.executable_path, Some("/path/to/chromium".to_string()));
}
#[test]
fn test_parse_executable_path_flag_no_value() {
let flags = parse_flags(&args("--executable-path"));
assert_eq!(flags.executable_path, None);
}
#[test]
fn test_clean_args_removes_executable_path() {
let cleaned = clean_args(&args("--executable-path /path/to/chromium open example.com"));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_clean_args_removes_executable_path_with_other_flags() {
let cleaned = clean_args(&args("--json --executable-path /path/to/chromium --headed open example.com"));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_parse_flags_with_session_and_executable_path() {
let flags = parse_flags(&args("--session test --executable-path /custom/chrome open example.com"));
assert_eq!(flags.session, "test");
assert_eq!(flags.executable_path, Some("/custom/chrome".to_string()));
}
}
+13 -42
View File
@@ -1,4 +1,3 @@
use crate::color;
use std::process::{exit, Command, Stdio};
pub fn run_install(with_deps: bool) {
@@ -6,15 +5,9 @@ pub fn run_install(with_deps: bool) {
if is_linux {
if with_deps {
println!("{}", color::cyan("Installing system dependencies..."));
println!("\x1b[36mInstalling system dependencies...\x1b[0m");
let (pkg_mgr, deps) = if which_exists("apt-get") {
let libasound = if package_exists_apt("libasound2t64") {
"libasound2t64"
} else {
"libasound2"
};
(
"apt-get",
vec![
@@ -37,7 +30,7 @@ pub fn run_install(with_deps: bool) {
"libcairo2",
"libgdk-pixbuf-2.0-0",
"libxrender1",
libasound,
"libasound2",
"libfreetype6",
"libfontconfig1",
"libdbus-1-3",
@@ -100,7 +93,7 @@ pub fn run_install(with_deps: bool) {
],
)
} else {
eprintln!("{} No supported package manager found (apt-get, dnf, or yum)", color::error_indicator());
eprintln!("\x1b[31m✗\x1b[0m No supported package manager found (apt-get, dnf, or yum)");
exit(1);
};
@@ -119,56 +112,45 @@ pub fn run_install(with_deps: bool) {
match status {
Ok(s) if s.success() => {
println!("{} System dependencies installed", color::success_indicator())
println!("\x1b[32m✓\x1b[0m System dependencies installed")
}
Ok(_) => eprintln!(
"{} Failed to install some dependencies. You may need to run manually with sudo.",
color::warning_indicator()
"\x1b[33m⚠\x1b[0m Failed to install some dependencies. You may need to run manually with sudo."
),
Err(e) => eprintln!("{} Could not run install command: {}", color::warning_indicator(), e),
Err(e) => eprintln!("\x1b[33m⚠\x1b[0m Could not run install command: {}", e),
}
} else {
println!("{} Linux detected. If browser fails to launch, run:", color::warning_indicator());
println!("\x1b[33m⚠\x1b[0m Linux detected. If browser fails to launch, run:");
println!(" agent-browser install --with-deps");
println!(" or: npx playwright install-deps chromium");
println!();
}
}
println!("{}", color::cyan("Installing Chromium browser..."));
// On Windows, we need to use cmd.exe to run npx because npx is actually npx.cmd
// and Command::new() doesn't resolve .cmd files the way the shell does.
// Pass the entire command as a single string to /c to handle paths with spaces.
#[cfg(windows)]
let status = Command::new("cmd")
.args(["/c", "npx playwright install chromium"])
.status();
#[cfg(not(windows))]
println!("\x1b[36mInstalling Chromium browser...\x1b[0m");
let status = Command::new("npx")
.args(["playwright", "install", "chromium"])
.status();
match status {
Ok(s) if s.success() => {
println!("{} Chromium installed successfully", color::success_indicator());
println!("\x1b[32m✓\x1b[0m Chromium installed successfully");
if is_linux && !with_deps {
println!();
println!("{} If you see \"shared library\" errors when running, use:", color::yellow("Note:"));
println!("\x1b[33mNote:\x1b[0m If you see \"shared library\" errors when running, use:");
println!(" agent-browser install --with-deps");
}
}
Ok(_) => {
eprintln!("{} Failed to install browser", color::error_indicator());
eprintln!("\x1b[31m✗\x1b[0m Failed to install browser");
if is_linux {
println!("{} Try installing system dependencies first:", color::yellow("Tip:"));
println!("\x1b[33mTip:\x1b[0m Try installing system dependencies first:");
println!(" agent-browser install --with-deps");
}
exit(1);
}
Err(e) => {
eprintln!("{} Failed to run npx: {}", color::error_indicator(), e);
eprintln!("\x1b[31m✗\x1b[0m Failed to run npx: {}", e);
eprintln!("Make sure Node.js is installed and npx is in your PATH");
exit(1);
}
@@ -197,14 +179,3 @@ fn which_exists(cmd: &str) -> bool {
.unwrap_or(false)
}
}
fn package_exists_apt(pkg: &str) -> bool {
Command::new("apt-cache")
.arg("show")
.arg(pkg)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
+15 -211
View File
@@ -1,4 +1,3 @@
mod color;
mod commands;
mod connection;
mod flags;
@@ -22,37 +21,7 @@ use commands::{gen_id, parse_command, ParseError};
use connection::{ensure_daemon, send_command};
use flags::{clean_args, parse_flags};
use install::run_install;
use output::{print_command_help, print_help, print_response, 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..]
})
}
use output::{print_help, print_response};
fn run_session(args: &[String], session: &str, json_mode: bool) {
let subcommand = args.get(1).map(|s| s.as_str());
@@ -108,7 +77,7 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
} else {
println!("Active sessions:");
for s in &sessions {
let marker = if s == session { color::cyan("") } else { " ".to_string() };
let marker = if s == session { "" } else { " " };
println!("{} {}", marker, s);
}
}
@@ -125,35 +94,11 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
}
fn main() {
// Ignore SIGPIPE to prevent panic when piping to head/tail
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
let args: Vec<String> = env::args().skip(1).collect();
let flags = parse_flags(&args);
let clean = clean_args(&args);
let has_help = args.iter().any(|a| a == "--help" || a == "-h");
let has_version = args.iter().any(|a| a == "--version" || a == "-V");
if has_help {
if let Some(cmd) = clean.get(0) {
if print_command_help(cmd) {
return;
}
}
print_help();
return;
}
if has_version {
print_version();
return;
}
if clean.is_empty() {
if clean.is_empty() || args.iter().any(|a| a == "--help" || a == "-h") {
print_help();
return;
}
@@ -186,109 +131,27 @@ fn main() {
error_type
);
} else {
eprintln!("{}", color::red(&e.format()));
eprintln!("\x1b[31m{}\x1b[0m", e.format());
}
exit(1);
}
};
let daemon_result = match ensure_daemon(&flags.session, flags.headed, flags.executable_path.as_deref(), &flags.extensions) {
Ok(result) => result,
Err(e) => {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, e);
} else {
eprintln!("{} {}", color::error_indicator(), e);
}
exit(1);
}
};
// Warn if executable_path was specified but daemon was already running
if daemon_result.already_running && (flags.executable_path.is_some() || !flags.extensions.is_empty()) {
if !flags.json {
if flags.executable_path.is_some() {
eprintln!("{} --executable-path ignored: daemon already running. Use 'agent-browser close' first to restart with new path.", color::warning_indicator());
}
if !flags.extensions.is_empty() {
eprintln!("{} --extension ignored: daemon already running. Use 'agent-browser close' first to restart with extensions.", color::warning_indicator());
}
if let Err(e) = ensure_daemon(&flags.session, flags.headed) {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, e);
} else {
eprintln!("\x1b[31m✗\x1b[0m {}", e);
}
exit(1);
}
// Connect via CDP if --cdp flag is set
if let Some(ref port) = flags.cdp {
let cdp_port: u16 = match port.parse::<u32>() {
Ok(p) if p == 0 => {
let msg = "Invalid CDP port: port must be greater than 0".to_string();
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
Ok(p) if p > 65535 => {
let msg = format!("Invalid CDP port: {} is out of range (valid range: 1-65535)", p);
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
Ok(p) => p as u16,
Err(_) => {
let msg = format!("Invalid CDP port: '{}' is not a valid number. Port must be a number between 1 and 65535", port);
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
};
let launch_cmd = json!({
"id": gen_id(),
"action": "launch",
"cdpPort": cdp_port
});
let err = match send_command(launch_cmd, &flags.session) {
Ok(resp) if resp.success => None,
Ok(resp) => Some(resp.error.unwrap_or_else(|| "CDP connection failed".to_string())),
Err(e) => Some(e.to_string()),
};
if let Some(msg) = err {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
}
// Launch headed browser or proxy if flags are set (without CDP)
if (flags.headed || flags.proxy.is_some()) && flags.cdp.is_none() {
let mut launch_cmd = json!({
"id": gen_id(),
"action": "launch",
"headless": !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 --headed flag is set, send launch command first to switch to headed mode
if flags.headed {
let launch_cmd = json!({ "id": gen_id(), "action": "launch", "headless": false });
if let Err(e) = send_command(launch_cmd, &flags.session) {
if !flags.json {
eprintln!("{} Could not configure browser: {}", color::warning_indicator(), e);
eprintln!("\x1b[33m⚠\x1b[0m Could not switch to headed mode: {}", e);
}
}
}
@@ -305,68 +168,9 @@ fn main() {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, e);
} else {
eprintln!("{} {}", color::error_indicator(), e);
eprintln!("\x1b[31m✗\x1b[0m {}", e);
}
exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_proxy_simple() {
let result = parse_proxy("http://proxy.com:8080");
assert_eq!(result["server"], "http://proxy.com:8080");
assert!(result.get("username").is_none());
assert!(result.get("password").is_none());
}
#[test]
fn test_parse_proxy_with_auth() {
let result = parse_proxy("http://user:pass@proxy.com:8080");
assert_eq!(result["server"], "http://proxy.com:8080");
assert_eq!(result["username"], "user");
assert_eq!(result["password"], "pass");
}
#[test]
fn test_parse_proxy_username_only() {
let result = parse_proxy("http://user@proxy.com:8080");
assert_eq!(result["server"], "http://proxy.com:8080");
assert_eq!(result["username"], "user");
assert_eq!(result["password"], "");
}
#[test]
fn test_parse_proxy_no_protocol() {
let result = parse_proxy("proxy.com:8080");
assert_eq!(result["server"], "proxy.com:8080");
assert!(result.get("username").is_none());
}
#[test]
fn test_parse_proxy_socks5() {
let result = parse_proxy("socks5://proxy.com:1080");
assert_eq!(result["server"], "socks5://proxy.com:1080");
assert!(result.get("username").is_none());
}
#[test]
fn test_parse_proxy_socks5_with_auth() {
let result = parse_proxy("socks5://admin:secret@proxy.com:1080");
assert_eq!(result["server"], "socks5://proxy.com:1080");
assert_eq!(result["username"], "admin");
assert_eq!(result["password"], "secret");
}
#[test]
fn test_parse_proxy_complex_password() {
let result = parse_proxy("http://user:p@ss:w0rd@proxy.com:8080");
assert_eq!(result["server"], "http://proxy.com:8080");
assert_eq!(result["username"], "user");
assert_eq!(result["password"], "p@ss:w0rd");
}
}
+19 -1126
View File
File diff suppressed because it is too large Load Diff
-41
View File
@@ -1,41 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
-18
View File
@@ -1,18 +0,0 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
-7
View File
@@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
-27
View File
@@ -1,27 +0,0 @@
{
"name": "docs",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.1.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"shiki": "^3.21.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.1",
"tailwindcss": "^4",
"typescript": "^5"
}
}
-4327
View File
File diff suppressed because it is too large Load Diff
-7
View File
@@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
-72
View File
@@ -1,72 +0,0 @@
import { CodeBlock } from "@/components/code-block";
export default function AgentMode() {
return (
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
<div className="prose">
<h1>Agent Mode</h1>
<p>
agent-browser works with any AI coding agent. Use <code>--json</code> for machine-readable output.
</p>
<h2>Compatible agents</h2>
<ul>
<li>Claude Code</li>
<li>Cursor</li>
<li>GitHub Copilot</li>
<li>OpenAI Codex</li>
<li>Google Gemini</li>
<li>opencode</li>
<li>Any agent that can run shell commands</li>
</ul>
<h2>JSON output</h2>
<CodeBlock code={`agent-browser snapshot --json
# {"success":true,"data":{"snapshot":"...","refs":{...}}}
agent-browser get text @e1 --json
agent-browser is visible @e2 --json`} />
<h2>Optimal workflow</h2>
<CodeBlock code={`# 1. Navigate and get snapshot
agent-browser open example.com
agent-browser snapshot -i --json # AI parses tree and refs
# 2. AI identifies target refs from snapshot
# 3. Execute actions using refs
agent-browser click @e2
agent-browser fill @e3 "input text"
# 4. Get new snapshot if page changed
agent-browser snapshot -i --json`} />
<h2>Integration</h2>
<h3>Just ask</h3>
<p>The simplest approach:</p>
<CodeBlock lang="text" code="Use agent-browser to test the login flow. Run agent-browser --help to see available commands." />
<p>The <code>--help</code> output is comprehensive.</p>
<h3>AGENTS.md / CLAUDE.md</h3>
<p>For consistent results, add to your instructions file:</p>
<CodeBlock lang="markdown" code={`## Browser Automation
Use \`agent-browser\` for web automation. Run \`agent-browser --help\` for all commands.
Core workflow:
1. \`agent-browser open <url>\` - Navigate to page
2. \`agent-browser snapshot -i\` - Get interactive elements with refs (@e1, @e2)
3. \`agent-browser click @e1\` / \`fill @e2 "text"\` - Interact using refs
4. Re-snapshot after page changes`} />
<h3>Claude Code skill</h3>
<p>For richer context:</p>
<CodeBlock code="cp -r node_modules/agent-browser/skills/agent-browser .claude/skills/" />
<p>Or download:</p>
<CodeBlock code={`mkdir -p .claude/skills/agent-browser
curl -o .claude/skills/agent-browser/SKILL.md \\
https://raw.githubusercontent.com/vercel-labs/agent-browser/main/skills/agent-browser/SKILL.md`} />
</div>
</div>
);
}
-83
View File
@@ -1,83 +0,0 @@
import { CodeBlock } from "@/components/code-block";
export default function CDPMode() {
return (
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
<div className="prose">
<h1>CDP Mode</h1>
<p>Connect to an existing browser via Chrome DevTools Protocol:</p>
<CodeBlock code={`# Start Chrome with: google-chrome --remote-debugging-port=9222
# Connect once, then run commands without --cdp
agent-browser connect 9222
agent-browser snapshot
agent-browser tab
agent-browser close
# Or pass --cdp on each command
agent-browser --cdp 9222 snapshot`} />
<h2>Use cases</h2>
<p>This enables control of:</p>
<ul>
<li>Electron apps</li>
<li>Chrome/Chromium with remote debugging</li>
<li>WebView2 applications</li>
<li>Any browser exposing a CDP endpoint</li>
</ul>
<h2>Global options</h2>
<table>
<thead>
<tr>
<th>Option</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>--session &lt;name&gt;</code></td>
<td>Use isolated session</td>
</tr>
<tr>
<td><code>--headers &lt;json&gt;</code></td>
<td>HTTP headers scoped to origin</td>
</tr>
<tr>
<td><code>--executable-path</code></td>
<td>Custom browser executable</td>
</tr>
<tr>
<td><code>--json</code></td>
<td>JSON output for agents</td>
</tr>
<tr>
<td><code>--full, -f</code></td>
<td>Full page screenshot</td>
</tr>
<tr>
<td><code>--name, -n</code></td>
<td>Locator name filter</td>
</tr>
<tr>
<td><code>--exact</code></td>
<td>Exact text match</td>
</tr>
<tr>
<td><code>--headed</code></td>
<td>Show browser window</td>
</tr>
<tr>
<td><code>--cdp &lt;port&gt;</code></td>
<td>CDP connection port</td>
</tr>
<tr>
<td><code>--debug</code></td>
<td>Debug output</td>
</tr>
</tbody>
</table>
</div>
</div>
);
}
-79
View File
@@ -1,79 +0,0 @@
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>
);
}
-121
View File
@@ -1,121 +0,0 @@
import { CodeBlock } from "@/components/code-block";
export default function Commands() {
return (
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
<div className="prose">
<h1>Commands</h1>
<h2>Core</h2>
<CodeBlock code={`agent-browser open <url> # Navigate (aliases: goto, navigate)
agent-browser click <sel> # Click element
agent-browser dblclick <sel> # Double-click
agent-browser fill <sel> <text> # Clear and fill
agent-browser type <sel> <text> # Type into element
agent-browser press <key> # Press key (Enter, Tab, Control+a)
agent-browser hover <sel> # Hover element
agent-browser select <sel> <val> # Select dropdown option
agent-browser check <sel> # Check checkbox
agent-browser uncheck <sel> # Uncheck checkbox
agent-browser scroll <dir> [px] # Scroll (up/down/left/right)
agent-browser screenshot [path] # Screenshot (--full for full page)
agent-browser snapshot # Accessibility tree with refs
agent-browser eval <js> # Run JavaScript
agent-browser close # Close browser`} />
<h2>Get info</h2>
<CodeBlock code={`agent-browser get text <sel> # Get text content
agent-browser get html <sel> # Get innerHTML
agent-browser get value <sel> # Get input value
agent-browser get attr <sel> <attr> # Get attribute
agent-browser get title # Get page title
agent-browser get url # Get current URL
agent-browser get count <sel> # Count matching elements
agent-browser get box <sel> # Get bounding box`} />
<h2>Check state</h2>
<CodeBlock code={`agent-browser is visible <sel> # Check if visible
agent-browser is enabled <sel> # Check if enabled
agent-browser is checked <sel> # Check if checked`} />
<h2>Find elements</h2>
<p>Semantic locators with actions (<code>click</code>, <code>fill</code>, <code>check</code>, <code>hover</code>, <code>text</code>):</p>
<CodeBlock code={`agent-browser find role <role> <action> [value]
agent-browser find text <text> <action>
agent-browser find label <label> <action> [value]
agent-browser find placeholder <ph> <action> [value]
agent-browser find testid <id> <action> [value]
agent-browser find first <sel> <action> [value]
agent-browser find nth <n> <sel> <action> [value]`} />
<p>Examples:</p>
<CodeBlock code={`agent-browser find role button click --name "Submit"
agent-browser find label "Email" fill "test@test.com"
agent-browser find first ".item" click`} />
<h2>Wait</h2>
<CodeBlock code={`agent-browser wait <selector> # Wait for element
agent-browser wait <ms> # Wait for time
agent-browser wait --text "Welcome" # Wait for text
agent-browser wait --url "**/dash" # Wait for URL pattern
agent-browser wait --load networkidle # Wait for load state
agent-browser wait --fn "condition" # Wait for JS condition`} />
<h2>Mouse</h2>
<CodeBlock code={`agent-browser mouse move <x> <y> # Move mouse
agent-browser mouse down [button] # Press button
agent-browser mouse up [button] # Release button
agent-browser mouse wheel <dy> [dx] # Scroll wheel`} />
<h2>Settings</h2>
<CodeBlock code={`agent-browser set viewport <w> <h> # Set viewport size
agent-browser set device <name> # Emulate device ("iPhone 14")
agent-browser set geo <lat> <lng> # Set geolocation
agent-browser set offline [on|off] # Toggle offline mode
agent-browser set headers <json> # Extra HTTP headers
agent-browser set credentials <u> <p> # HTTP basic auth
agent-browser set media [dark|light] # Emulate color scheme`} />
<h2>Cookies & storage</h2>
<CodeBlock code={`agent-browser cookies # Get all cookies
agent-browser cookies set <name> <val> # 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
agent-browser storage session # Same for sessionStorage`} />
<h2>Network</h2>
<CodeBlock code={`agent-browser network route <url> # Intercept requests
agent-browser network route <url> --abort # Block requests
agent-browser network route <url> --body <json> # Mock response
agent-browser network unroute [url] # Remove routes
agent-browser network requests # View tracked requests`} />
<h2>Tabs & frames</h2>
<CodeBlock code={`agent-browser tab # List tabs
agent-browser tab new [url] # New tab
agent-browser tab <n> # Switch to tab
agent-browser tab close [n] # Close tab
agent-browser frame <sel> # Switch to iframe
agent-browser frame main # Back to main frame`} />
<h2>Debug</h2>
<CodeBlock code={`agent-browser trace start [path] # Start trace
agent-browser trace stop [path] # Stop and save trace
agent-browser console # View console messages
agent-browser errors # View page errors
agent-browser highlight <sel> # Highlight element
agent-browser state save <path> # Save auth state
agent-browser state load <path> # Load auth state`} />
<h2>Navigation</h2>
<CodeBlock code={`agent-browser back # Go back
agent-browser forward # Go forward
agent-browser reload # Reload page`} />
</div>
</div>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

-182
View File
@@ -1,182 +0,0 @@
@import "tailwindcss";
:root {
--background: #000000;
--foreground: #ededed;
--muted: #888888;
--border: #222222;
--accent: #ededed;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-border: var(--border);
--color-accent: var(--accent);
--font-sans: var(--font-geist);
--font-mono: var(--font-geist-mono);
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-geist), system-ui, sans-serif;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #333;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #444;
}
/* Code blocks */
pre {
background: #111 !important;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.875rem;
overflow-x: auto;
font-family: var(--font-geist-mono), monospace;
font-size: 0.8125rem;
line-height: 1.7;
}
.code-block pre {
margin: 0;
}
.code-block {
margin-bottom: 1.25rem;
}
@media (max-width: 640px) {
pre {
font-size: 0.75rem;
padding: 0.75rem;
}
}
code {
font-family: var(--font-geist-mono), monospace;
}
:not(pre) > code {
background: #1a1a1a;
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: 0.875em;
}
/* Prose */
.prose {
max-width: 100%;
}
.prose h1 {
font-size: 1.5rem;
font-weight: 500;
letter-spacing: -0.02em;
margin-bottom: 0.5rem;
color: #fff;
}
@media (min-width: 640px) {
.prose h1 {
font-size: 1.75rem;
}
}
.prose h2 {
font-size: 0.875rem;
font-weight: 500;
letter-spacing: 0;
text-transform: uppercase;
color: var(--muted);
margin-top: 3rem;
margin-bottom: 1rem;
}
.prose h3 {
font-size: 0.875rem;
font-weight: 500;
margin-top: 2rem;
margin-bottom: 0.75rem;
color: #ccc;
}
.prose p {
margin-bottom: 1.25rem;
line-height: 1.7;
color: var(--muted);
font-size: 0.9375rem;
}
.prose ul, .prose ol {
margin-bottom: 1.25rem;
padding-left: 1.25rem;
}
.prose li {
margin-bottom: 0.5rem;
color: var(--muted);
font-size: 0.9375rem;
line-height: 1.6;
}
.prose li strong {
color: #ccc;
font-weight: 500;
}
.prose a {
color: var(--foreground);
text-decoration: underline;
text-underline-offset: 2px;
}
.prose a:hover {
color: #fff;
}
.prose table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
font-size: 0.8125rem;
}
.prose th, .prose td {
text-align: left;
padding: 0.625rem 0.875rem;
border-bottom: 1px solid var(--border);
}
.prose th {
font-weight: 500;
color: var(--muted);
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.025em;
}
.prose td {
color: var(--muted);
}
.prose td code {
color: var(--foreground);
}
-58
View File
@@ -1,58 +0,0 @@
import { CodeBlock } from "@/components/code-block";
export default function Installation() {
return (
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
<div className="prose">
<h1>Installation</h1>
<h2>npm (recommended)</h2>
<CodeBlock code={`npm install -g agent-browser
agent-browser install # Download Chromium`} />
<h2>From source</h2>
<CodeBlock code={`git clone https://github.com/vercel-labs/agent-browser
cd agent-browser
pnpm install
pnpm build
pnpm build:native
./bin/agent-browser install
pnpm link --global`} />
<h2>Linux dependencies</h2>
<p>On Linux, install system dependencies:</p>
<CodeBlock code={`agent-browser install --with-deps
# or manually: npx playwright install-deps chromium`} />
<h2>Custom browser</h2>
<p>
Use a custom browser executable instead of bundled Chromium:
</p>
<ul>
<li><strong>Serverless</strong> - Use <code>@sparticuz/chromium</code> (~50MB vs ~684MB)</li>
<li><strong>System browser</strong> - Use existing Chrome installation</li>
<li><strong>Custom builds</strong> - Use modified browser builds</li>
</ul>
<CodeBlock code={`# Via flag
agent-browser --executable-path /path/to/chromium open example.com
# Via environment variable
AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chromium agent-browser open example.com`} />
<h3>Serverless example</h3>
<CodeBlock lang="typescript" code={`import chromium from '@sparticuz/chromium';
import { BrowserManager } from 'agent-browser';
export async function handler() {
const browser = new BrowserManager();
await browser.launch({
executablePath: await chromium.executablePath(),
headless: true,
});
// ... use browser
}`} />
</div>
</div>
);
}
-40
View File
@@ -1,40 +0,0 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Sidebar } from "@/components/sidebar";
const geist = Geist({
variable: "--font-geist",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "agent-browser",
description: "Headless browser automation CLI for AI agents",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark">
<body
className={`${geist.variable} ${geistMono.variable} antialiased bg-zinc-950 text-zinc-100`}
>
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 overflow-auto pt-14 lg:pt-0">
{children}
</main>
</div>
</body>
</html>
);
}
-69
View File
@@ -1,69 +0,0 @@
import { CodeBlock } from "@/components/code-block";
export default function Home() {
return (
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
<div className="prose">
<h1>agent-browser</h1>
<p>
Headless browser automation CLI for AI agents. Fast Rust CLI with Node.js fallback.
</p>
<CodeBlock code="npm install -g agent-browser" />
<h2>Features</h2>
<ul>
<li><strong>Universal</strong> - Works with any AI agent: Claude Code, Cursor, Codex, Copilot, Gemini, opencode, and more</li>
<li><strong>AI-first</strong> - Snapshot returns accessibility tree with refs for deterministic element selection</li>
<li><strong>Fast</strong> - Native Rust CLI for instant command parsing</li>
<li><strong>Complete</strong> - 50+ commands for navigation, forms, screenshots, network, storage</li>
<li><strong>Sessions</strong> - Multiple isolated browser instances with separate auth</li>
<li><strong>Cross-platform</strong> - macOS, Linux, Windows with native binaries</li>
<li><strong>Serverless</strong> - Custom executable path for lightweight Chromium builds</li>
</ul>
<h2>Example</h2>
<CodeBlock code={`# Navigate and get snapshot
agent-browser open example.com
agent-browser snapshot -i
# Output:
# - heading "Example Domain" [ref=e1]
# - link "More information..." [ref=e2]
# Interact using refs
agent-browser click @e2
agent-browser screenshot page.png
agent-browser close`} />
<h2>Why refs?</h2>
<p>
The <code>snapshot</code> command returns an accessibility tree where each element
has a unique ref like <code>@e1</code>, <code>@e2</code>. This provides:
</p>
<ul>
<li><strong>Deterministic</strong> - Ref points to exact element from snapshot</li>
<li><strong>Fast</strong> - No DOM re-query needed</li>
<li><strong>AI-friendly</strong> - LLMs can reliably parse and use refs</li>
</ul>
<h2>Architecture</h2>
<p>
Client-daemon architecture for optimal performance:
</p>
<ol>
<li><strong>Rust CLI</strong> - Parses commands, communicates with daemon</li>
<li><strong>Node.js Daemon</strong> - Manages Playwright browser instance</li>
</ol>
<p>
Daemon starts automatically and persists between commands.
</p>
<h2>Platforms</h2>
<p>
Native Rust binaries for macOS (ARM64, x64), Linux (ARM64, x64), and Windows (x64).
</p>
</div>
</div>
);
}
-51
View File
@@ -1,51 +0,0 @@
import { CodeBlock } from "@/components/code-block";
export default function QuickStart() {
return (
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
<div className="prose">
<h1>Quick Start</h1>
<h2>Basic workflow</h2>
<CodeBlock code={`agent-browser open example.com
agent-browser snapshot # Get accessibility tree with refs
agent-browser click @e2 # Click by ref from snapshot
agent-browser fill @e3 "test@example.com" # Fill by ref
agent-browser get text @e1 # Get text by ref
agent-browser screenshot # Base64 png to stdout
agent-browser screenshot page.png # Save to file
agent-browser close`} />
<h2>Traditional selectors</h2>
<p>CSS selectors and semantic locators also supported:</p>
<CodeBlock code={`agent-browser click "#submit"
agent-browser fill "#email" "test@example.com"
agent-browser find role button click --name "Submit"`} />
<h2>AI workflow</h2>
<p>Optimal workflow for AI agents:</p>
<CodeBlock code={`# 1. Navigate and get snapshot
agent-browser open example.com
agent-browser snapshot -i --json # AI parses tree and refs
# 2. AI identifies target refs from snapshot
# 3. Execute actions using refs
agent-browser click @e2
agent-browser fill @e3 "input text"
# 4. Get new snapshot if page changed
agent-browser snapshot -i --json`} />
<h2>Headed mode</h2>
<p>Show browser window for debugging:</p>
<CodeBlock code="agent-browser open example.com --headed" />
<h2>JSON output</h2>
<p>Use <code>--json</code> for machine-readable output:</p>
<CodeBlock code={`agent-browser snapshot --json
agent-browser get text @e1 --json
agent-browser is visible @e2 --json`} />
</div>
</div>
);
}
-53
View File
@@ -1,53 +0,0 @@
import { CodeBlock } from "@/components/code-block";
export default function Selectors() {
return (
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
<div className="prose">
<h1>Selectors</h1>
<h2>Refs (recommended)</h2>
<p>
Refs provide deterministic element selection from snapshots. Best for AI agents.
</p>
<CodeBlock code={`# 1. Get snapshot with refs
agent-browser snapshot
# Output:
# - heading "Example Domain" [ref=e1] [level=1]
# - button "Submit" [ref=e2]
# - textbox "Email" [ref=e3]
# - link "Learn more" [ref=e4]
# 2. Use refs to interact
agent-browser click @e2 # Click the button
agent-browser fill @e3 "test@example.com" # Fill the textbox
agent-browser get text @e1 # Get heading text
agent-browser hover @e4 # Hover the link`} />
<h3>Why refs?</h3>
<ul>
<li><strong>Deterministic</strong> - Ref points to exact element from snapshot</li>
<li><strong>Fast</strong> - No DOM re-query needed</li>
<li><strong>AI-friendly</strong> - LLMs can reliably parse and use refs</li>
</ul>
<h2>CSS selectors</h2>
<CodeBlock code={`agent-browser click "#id"
agent-browser click ".class"
agent-browser click "div > button"
agent-browser click "[data-testid='submit']"`} />
<h2>Text & XPath</h2>
<CodeBlock code={`agent-browser click "text=Submit"
agent-browser click "xpath=//button[@type='submit']"`} />
<h2>Semantic locators</h2>
<p>Find elements by role, label, or other semantic properties:</p>
<CodeBlock code={`agent-browser find role button click --name "Submit"
agent-browser find label "Email" fill "test@test.com"
agent-browser find placeholder "Search..." fill "query"
agent-browser find testid "submit-btn" click`} />
</div>
</div>
);
}
-66
View File
@@ -1,66 +0,0 @@
import { CodeBlock } from "@/components/code-block";
export default function Sessions() {
return (
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
<div className="prose">
<h1>Sessions</h1>
<p>Run multiple isolated browser instances:</p>
<CodeBlock code={`# Different sessions
agent-browser --session agent1 open site-a.com
agent-browser --session agent2 open site-b.com
# Or via environment variable
AGENT_BROWSER_SESSION=agent1 agent-browser click "#btn"
# List active sessions
agent-browser session list
# Output:
# Active sessions:
# -> default
# agent1
# Show current session
agent-browser session`} />
<h2>Session isolation</h2>
<p>Each session has its own:</p>
<ul>
<li>Browser instance</li>
<li>Cookies and storage</li>
<li>Navigation history</li>
<li>Authentication state</li>
</ul>
<h2>Authenticated sessions</h2>
<p>
Use <code>--headers</code> to set HTTP headers for a specific origin:
</p>
<CodeBlock code={`# Headers scoped to api.example.com only
agent-browser open api.example.com --headers '{"Authorization": "Bearer <token>"}'
# Requests to api.example.com include the auth header
agent-browser snapshot -i --json
agent-browser click @e2
# Navigate to another domain - headers NOT sent
agent-browser open other-site.com`} />
<p>Useful for:</p>
<ul>
<li><strong>Skipping login flows</strong> - Authenticate via headers</li>
<li><strong>Switching users</strong> - Different auth tokens per session</li>
<li><strong>API testing</strong> - Access protected endpoints</li>
<li><strong>Security</strong> - Headers scoped to origin, not leaked</li>
</ul>
<h2>Multiple origins</h2>
<CodeBlock code={`agent-browser open api.example.com --headers '{"Authorization": "Bearer token1"}'
agent-browser open api.acme.com --headers '{"Authorization": "Bearer token2"}'`} />
<h2>Global headers</h2>
<p>For headers on all domains:</p>
<CodeBlock code={`agent-browser set headers '{"X-Custom-Header": "value"}'`} />
</div>
</div>
);
}
-71
View File
@@ -1,71 +0,0 @@
import { CodeBlock } from "@/components/code-block";
export default function Snapshots() {
return (
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
<div className="prose">
<h1>Snapshots</h1>
<p>
The <code>snapshot</code> command returns the accessibility tree with refs for AI-friendly interaction.
</p>
<h2>Options</h2>
<p>Filter output to reduce size:</p>
<CodeBlock code={`agent-browser snapshot # Full accessibility tree
agent-browser snapshot -i # Interactive elements only
agent-browser snapshot -c # Compact (remove empty elements)
agent-browser snapshot -d 3 # Limit depth to 3 levels
agent-browser snapshot -s "#main" # Scope to CSS selector
agent-browser snapshot -i -c -d 5 # Combine options`} />
<table>
<thead>
<tr>
<th>Option</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>-i, --interactive</code></td>
<td>Only interactive elements (buttons, links, inputs)</td>
</tr>
<tr>
<td><code>-c, --compact</code></td>
<td>Remove empty structural elements</td>
</tr>
<tr>
<td><code>-d, --depth</code></td>
<td>Limit tree depth</td>
</tr>
<tr>
<td><code>-s, --selector</code></td>
<td>Scope to CSS selector</td>
</tr>
</tbody>
</table>
<h2>Output format</h2>
<CodeBlock code={`agent-browser snapshot
# Output:
# - heading "Example Domain" [ref=e1] [level=1]
# - button "Submit" [ref=e2]
# - textbox "Email" [ref=e3]
# - link "Learn more" [ref=e4]`} />
<h2>JSON output</h2>
<p>Use <code>--json</code> for machine-readable output:</p>
<CodeBlock code={`agent-browser snapshot --json
# {"success":true,"data":{"snapshot":"...","refs":{"e1":{"role":"heading","name":"Title"},...}}}`} />
<h2>Best practices</h2>
<ol>
<li>Use <code>-i</code> to reduce output to actionable elements</li>
<li>Use <code>--json</code> for structured parsing</li>
<li>Re-snapshot after page changes to get updated refs</li>
<li>Scope with <code>-s</code> for specific page sections</li>
</ol>
</div>
</div>
);
}
-217
View File
@@ -1,217 +0,0 @@
import { CodeBlock } from "@/components/code-block";
export default function Streaming() {
return (
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
<div className="prose">
<h1>Streaming</h1>
<p>
Stream the browser viewport via WebSocket for live preview or &quot;pair browsing&quot;
where a human can watch and interact alongside an AI agent.
</p>
<h2>Enable streaming</h2>
<p>
Set the <code>AGENT_BROWSER_STREAM_PORT</code> environment variable to start
a WebSocket server:
</p>
<CodeBlock code={`AGENT_BROWSER_STREAM_PORT=9223 agent-browser open example.com`} />
<p>
The server streams viewport frames and accepts input events (mouse, keyboard, touch).
</p>
<h2>WebSocket protocol</h2>
<p>Connect to <code>ws://localhost:9223</code> to receive frames and send input.</p>
<h3>Frame messages</h3>
<p>The server sends frame messages with base64-encoded images:</p>
<CodeBlock code={`{
"type": "frame",
"data": "<base64-encoded-jpeg>",
"metadata": {
"deviceWidth": 1280,
"deviceHeight": 720,
"pageScaleFactor": 1,
"offsetTop": 0,
"scrollOffsetX": 0,
"scrollOffsetY": 0
}
}`} />
<h3>Status messages</h3>
<p>Connection and screencast status:</p>
<CodeBlock code={`{
"type": "status",
"connected": true,
"screencasting": true,
"viewportWidth": 1280,
"viewportHeight": 720
}`} />
<h2>Input injection</h2>
<p>Send input events to control the browser remotely.</p>
<h3>Mouse events</h3>
<CodeBlock code={`// Click
{
"type": "input_mouse",
"eventType": "mousePressed",
"x": 100,
"y": 200,
"button": "left",
"clickCount": 1
}
// Release
{
"type": "input_mouse",
"eventType": "mouseReleased",
"x": 100,
"y": 200,
"button": "left"
}
// Move
{
"type": "input_mouse",
"eventType": "mouseMoved",
"x": 150,
"y": 250
}
// Scroll
{
"type": "input_mouse",
"eventType": "mouseWheel",
"x": 100,
"y": 200,
"deltaX": 0,
"deltaY": 100
}`} />
<h3>Keyboard events</h3>
<CodeBlock code={`// Key down
{
"type": "input_keyboard",
"eventType": "keyDown",
"key": "Enter",
"code": "Enter"
}
// Key up
{
"type": "input_keyboard",
"eventType": "keyUp",
"key": "Enter",
"code": "Enter"
}
// Type character
{
"type": "input_keyboard",
"eventType": "char",
"text": "a"
}
// With modifiers (1=Alt, 2=Ctrl, 4=Meta, 8=Shift)
{
"type": "input_keyboard",
"eventType": "keyDown",
"key": "c",
"code": "KeyC",
"modifiers": 2
}`} />
<h3>Touch events</h3>
<CodeBlock code={`// Touch start
{
"type": "input_touch",
"eventType": "touchStart",
"touchPoints": [{ "x": 100, "y": 200 }]
}
// Touch move
{
"type": "input_touch",
"eventType": "touchMove",
"touchPoints": [{ "x": 150, "y": 250 }]
}
// Touch end
{
"type": "input_touch",
"eventType": "touchEnd",
"touchPoints": []
}
// Multi-touch (pinch zoom)
{
"type": "input_touch",
"eventType": "touchStart",
"touchPoints": [
{ "x": 100, "y": 200, "id": 0 },
{ "x": 200, "y": 200, "id": 1 }
]
}`} />
<h2>Programmatic API</h2>
<p>For advanced use, control streaming directly via the TypeScript API:</p>
<CodeBlock code={`import { BrowserManager } from 'agent-browser';
const browser = new BrowserManager();
await browser.launch({ headless: true });
await browser.navigate('https://example.com');
// Start screencast with callback
await browser.startScreencast((frame) => {
console.log('Frame:', frame.metadata.deviceWidth, 'x', frame.metadata.deviceHeight);
// frame.data is base64-encoded image
}, {
format: 'jpeg', // or 'png'
quality: 80, // 0-100, jpeg only
maxWidth: 1280,
maxHeight: 720,
everyNthFrame: 1
});
// Inject mouse event
await browser.injectMouseEvent({
type: 'mousePressed',
x: 100,
y: 200,
button: 'left',
clickCount: 1
});
// Inject keyboard event
await browser.injectKeyboardEvent({
type: 'keyDown',
key: 'Enter',
code: 'Enter'
});
// Inject touch event
await browser.injectTouchEvent({
type: 'touchStart',
touchPoints: [{ x: 100, y: 200 }]
});
// Check if screencasting
console.log('Active:', browser.isScreencasting());
// Stop screencast
await browser.stopScreencast();`} />
<h2>Use cases</h2>
<ul>
<li><strong>Pair browsing</strong> - Human watches and assists AI agent in real-time</li>
<li><strong>Remote preview</strong> - View browser output in a separate UI</li>
<li><strong>Recording</strong> - Capture frames for video generation</li>
<li><strong>Mobile testing</strong> - Inject touch events for mobile emulation</li>
<li><strong>Accessibility testing</strong> - Manual interaction during automated tests</li>
</ul>
</div>
</div>
);
}
-22
View File
@@ -1,22 +0,0 @@
import { codeToHtml } from "shiki";
import { CopyButton } from "./copy-button";
interface CodeBlockProps {
code: string;
lang?: string;
}
export async function CodeBlock({ code, lang = "bash" }: CodeBlockProps) {
const trimmedCode = code.trim();
const html = await codeToHtml(trimmedCode, {
lang,
theme: "github-dark-default",
});
return (
<div className="code-block relative group">
<CopyButton code={trimmedCode} />
<div dangerouslySetInnerHTML={{ __html: html }} />
</div>
);
}
-40
View File
@@ -1,40 +0,0 @@
"use client";
import { useState } from "react";
interface CopyButtonProps {
code: string;
}
export function CopyButton({ code }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error("Failed to copy to clipboard:", error);
// Optionally, you could set an error state or show a toast notification here
}
};
return (
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded text-[#666] hover:text-[#999] hover:bg-[#333] opacity-0 group-hover:opacity-100 transition-all"
aria-label="Copy code"
>
{copied ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
);
}
-132
View File
@@ -1,132 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState, useEffect } from "react";
const navigation = [
{ name: "Introduction", href: "/" },
{ name: "Installation", href: "/installation" },
{ name: "Quick Start", href: "/quick-start" },
{ name: "Commands", href: "/commands" },
{ name: "Selectors", href: "/selectors" },
{ name: "Sessions", href: "/sessions" },
{ name: "Snapshots", href: "/snapshots" },
{ name: "Streaming", href: "/streaming" },
{ name: "Agent Mode", href: "/agent-mode" },
{ name: "CDP Mode", href: "/cdp-mode" },
{ name: "Changelog", href: "/changelog" },
];
export function Sidebar() {
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
setIsOpen(false);
}, [pathname]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsOpen(false);
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, []);
return (
<>
{/* Mobile header */}
<header className="lg:hidden fixed top-0 left-0 right-0 z-50 bg-black/90 backdrop-blur-sm border-b border-[#222] px-4 py-3">
<div className="flex items-center justify-between">
<Link href="/" className="text-sm font-medium">
agent-browser
</Link>
<button
onClick={() => setIsOpen(!isOpen)}
className="p-2 -mr-2 text-[#888] hover:text-white transition-colors"
aria-label="Toggle menu"
>
{isOpen ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
</div>
</header>
{/* Mobile overlay */}
{isOpen && (
<div
className="lg:hidden fixed inset-0 z-40 bg-black/80"
onClick={() => setIsOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`
fixed lg:sticky top-0 left-0 z-50 lg:z-auto
w-56 lg:w-48 h-screen
bg-black border-r border-[#222]
transform transition-transform duration-150 ease-out
${isOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0"}
pt-14 lg:pt-0
`}
>
<div className="h-full overflow-y-auto p-5">
{/* Desktop header */}
<div className="mb-8 hidden lg:block">
<Link href="/" className="text-sm font-medium">
agent-browser
</Link>
</div>
<nav className="space-y-0.5">
{navigation.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={`block px-2 py-1.5 text-[13px] transition-colors ${
isActive
? "text-white"
: "text-[#666] hover:text-[#999]"
}`}
>
{item.name}
</Link>
);
})}
</nav>
<div className="mt-8 pt-4 border-t border-[#222] space-y-0.5">
<a
href="https://github.com/vercel-labs/agent-browser"
target="_blank"
rel="noopener noreferrer"
className="block px-2 py-1.5 text-[13px] text-[#666] hover:text-[#999] transition-colors"
>
GitHub
</a>
<a
href="https://www.npmjs.com/package/agent-browser"
target="_blank"
rel="noopener noreferrer"
className="block px-2 py-1.5 text-[13px] text-[#666] hover:text-[#999] transition-colors"
>
npm
</a>
</div>
</div>
</aside>
</>
);
}
-34
View File
@@ -1,34 +0,0 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+1 -3
View File
@@ -1,6 +1,6 @@
{
"name": "agent-browser",
"version": "0.5.0",
"version": "0.4.2",
"description": "Headless browser automation CLI for AI agents",
"type": "module",
"main": "dist/daemon.js",
@@ -53,12 +53,10 @@
"homepage": "https://github.com/vercel-labs/agent-browser#readme",
"dependencies": {
"playwright-core": "^1.57.0",
"ws": "^8.19.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "^20.10.0",
"@types/ws": "^8.18.1",
"husky": "^9.1.7",
"lint-staged": "^15.2.11",
"playwright": "^1.57.0",
-27
View File
@@ -11,9 +11,6 @@ importers:
playwright-core:
specifier: ^1.57.0
version: 1.57.0
ws:
specifier: ^8.19.0
version: 8.19.0
zod:
specifier: ^3.22.4
version: 3.25.76
@@ -21,9 +18,6 @@ importers:
'@types/node':
specifier: ^20.10.0
version: 20.19.28
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
husky:
specifier: ^9.1.7
version: 9.1.7
@@ -347,9 +341,6 @@ packages:
'@types/node@20.19.28':
resolution: {integrity: sha512-VyKBr25BuFDzBFCK5sUM6ZXiWfqgCTwTAOK8qzGV/m9FCirXYDlmczJ+d5dXBAQALGCdRRdbteKYfJ84NGEusw==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@vitest/expect@4.0.16':
resolution: {integrity: sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==}
@@ -814,18 +805,6 @@ packages:
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
engines: {node: '>=18'}
ws@8.19.0:
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
yaml@2.8.2:
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
engines: {node: '>= 14.6'}
@@ -1006,10 +985,6 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/ws@8.18.1':
dependencies:
'@types/node': 20.19.28
'@vitest/expect@4.0.16':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -1452,8 +1427,6 @@ snapshots:
string-width: 7.2.0
strip-ansi: 7.1.2
ws@8.19.0: {}
yaml@2.8.2: {}
zod@3.25.76: {}
+6 -115
View File
@@ -1,7 +1,6 @@
---
name: agent-browser
description: Automates browser interactions for web testing, form filling, screenshots, and data extraction. Use when the user needs to navigate websites, interact with web pages, fill forms, take screenshots, test web applications, or extract information from web pages.
allowed-tools: Bash(agent-browser:*)
---
# Browser Automation with agent-browser
@@ -29,93 +28,56 @@ agent-browser close # Close browser
```bash
agent-browser open <url> # Navigate to URL
agent-browser back # Go back
agent-browser forward # Go forward
agent-browser forward # Go forward
agent-browser reload # Reload page
agent-browser close # Close browser
```
### Snapshot (page analysis)
```bash
agent-browser snapshot # Full accessibility tree
agent-browser snapshot -i # Interactive elements only (recommended)
agent-browser snapshot -c # Compact output
agent-browser snapshot -d 3 # Limit depth to 3
agent-browser snapshot -s "#main" # Scope to CSS selector
agent-browser snapshot # Full accessibility tree
agent-browser snapshot -i # Interactive elements only (recommended)
agent-browser snapshot -c # Compact output
agent-browser snapshot -d 3 # Limit depth to 3
```
### Interactions (use @refs from snapshot)
```bash
agent-browser click @e1 # Click
agent-browser dblclick @e1 # Double-click
agent-browser focus @e1 # Focus element
agent-browser fill @e2 "text" # Clear and type
agent-browser type @e2 "text" # Type without clearing
agent-browser press Enter # Press key
agent-browser press Control+a # Key combination
agent-browser keydown Shift # Hold key down
agent-browser keyup Shift # Release key
agent-browser hover @e1 # Hover
agent-browser check @e1 # Check checkbox
agent-browser uncheck @e1 # Uncheck checkbox
agent-browser select @e1 "value" # Select dropdown
agent-browser scroll down 500 # Scroll page
agent-browser scrollintoview @e1 # Scroll element into view
agent-browser drag @e1 @e2 # Drag and drop
agent-browser upload @e1 file.pdf # Upload files
```
### Get information
```bash
agent-browser get text @e1 # Get element text
agent-browser get html @e1 # Get innerHTML
agent-browser get value @e1 # Get input value
agent-browser get attr @e1 href # Get attribute
agent-browser get title # Get page title
agent-browser get url # Get current URL
agent-browser get count ".item" # Count matching elements
agent-browser get box @e1 # Get bounding box
```
### 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
### Screenshots
```bash
agent-browser screenshot # Screenshot to stdout
agent-browser screenshot path.png # Save to file
agent-browser screenshot --full # Full page
agent-browser pdf output.pdf # Save as PDF
```
### Video recording
```bash
agent-browser record start ./demo.webm # Start recording (uses current URL + state)
agent-browser click @e1 # Perform actions
agent-browser record stop # Stop and save video
agent-browser record restart ./take2.webm # Stop current + start new recording
```
Recording creates a fresh context but preserves cookies/storage from your session. If no URL is provided, it automatically returns to your current page. For smooth demos, explore first, then start recording.
### Wait
```bash
agent-browser wait @e1 # Wait for element
agent-browser wait 2000 # Wait milliseconds
agent-browser wait --text "Success" # Wait for text
agent-browser wait --url "**/dashboard" # Wait for URL pattern
agent-browser wait --load networkidle # Wait for network idle
agent-browser wait --fn "window.ready" # Wait for JS condition
```
### Mouse control
```bash
agent-browser mouse move 100 200 # Move mouse
agent-browser mouse down left # Press button
agent-browser mouse up left # Release button
agent-browser mouse wheel 100 # Scroll wheel
```
### Semantic locators (alternative to refs)
@@ -123,66 +85,6 @@ agent-browser mouse wheel 100 # Scroll wheel
agent-browser find role button click --name "Submit"
agent-browser find text "Sign In" click
agent-browser find label "Email" fill "user@test.com"
agent-browser find first ".item" click
agent-browser find nth 2 "a" text
```
### Browser settings
```bash
agent-browser set viewport 1920 1080 # Set viewport size
agent-browser set device "iPhone 14" # Emulate device
agent-browser set geo 37.7749 -122.4194 # Set geolocation
agent-browser set offline on # Toggle offline mode
agent-browser set headers '{"X-Key":"v"}' # Extra HTTP headers
agent-browser set credentials user pass # HTTP basic auth
agent-browser set media dark # Emulate color scheme
```
### Cookies & Storage
```bash
agent-browser cookies # Get all cookies
agent-browser cookies set name value # Set cookie
agent-browser cookies clear # Clear cookies
agent-browser storage local # Get all localStorage
agent-browser storage local key # Get specific key
agent-browser storage local set k v # Set value
agent-browser storage local clear # Clear all
```
### Network
```bash
agent-browser network route <url> # Intercept requests
agent-browser network route <url> --abort # Block requests
agent-browser network route <url> --body '{}' # Mock response
agent-browser network unroute [url] # Remove routes
agent-browser network requests # View tracked requests
agent-browser network requests --filter api # Filter requests
```
### Tabs & Windows
```bash
agent-browser tab # List tabs
agent-browser tab new [url] # New tab
agent-browser tab 2 # Switch to tab
agent-browser tab close # Close tab
agent-browser window new # New window
```
### Frames
```bash
agent-browser frame "#iframe" # Switch to iframe
agent-browser frame main # Back to main frame
```
### Dialogs
```bash
agent-browser dialog accept [text] # Accept dialog
agent-browser dialog dismiss # Dismiss dialog
```
### JavaScript
```bash
agent-browser eval "document.title" # Run JavaScript
```
## Example: Form submission
@@ -235,18 +137,7 @@ agent-browser get text @e1 --json
## Debugging
```bash
agent-browser open example.com --headed # Show browser window
agent-browser console # View console messages
agent-browser errors # View page errors
agent-browser record start ./debug.webm # Record from current page
agent-browser record stop # Save recording
agent-browser open example.com --headed # Show browser window
agent-browser --cdp 9222 snapshot # Connect via CDP
agent-browser console # View console messages
agent-browser console --clear # Clear console
agent-browser errors # View page errors
agent-browser errors --clear # Clear errors
agent-browser highlight @e1 # Highlight element
agent-browser trace start # Start recording trace
agent-browser trace stop trace.zip # Stop and save trace
```
-39
View File
@@ -1,39 +0,0 @@
import { describe, it, expect } from 'vitest';
import { toAIFriendlyError } from './actions.js';
describe('toAIFriendlyError', () => {
describe('element blocked by overlay', () => {
it('should detect intercepts pointer events even when Timeout is in message', () => {
// This is the exact error from Playwright when a cookie banner blocks an element
// Bug: Previously this was incorrectly reported as "not found or not visible"
const error = new Error(
'TimeoutError: locator.click: Timeout 10000ms exceeded.\n' +
'Call log:\n' +
" - waiting for getByRole('link', { name: 'Anmelden', exact: true }).first()\n" +
' - locator resolved to <a href="https://example.com/login">Anmelden</a>\n' +
' - attempting click action\n' +
' 2 x waiting for element to be visible, enabled and stable\n' +
' - element is visible, enabled and stable\n' +
' - scrolling into view if needed\n' +
' - done scrolling\n' +
' - <body class="font-sans antialiased">...</body> intercepts pointer events\n' +
' - retrying click action'
);
const result = toAIFriendlyError(error, '@e4');
// Must NOT say "not found" - the element WAS found
expect(result.message).not.toContain('not found');
// Must indicate the element is blocked
expect(result.message).toContain('blocked by another element');
expect(result.message).toContain('modal or overlay');
});
it('should suggest dismissing cookie banners', () => {
const error = new Error('<div class="cookie-overlay"> intercepts pointer events');
const result = toAIFriendlyError(error, '@e1');
expect(result.message).toContain('cookie banners');
});
});
});
+15 -252
View File
@@ -1,5 +1,5 @@
import type { Page, Frame } from 'playwright-core';
import type { BrowserManager, ScreencastFrame } from './browser.js';
import type { BrowserManager } from './browser.js';
import type {
Command,
Response,
@@ -26,7 +26,6 @@ import type {
SelectCommand,
HoverCommand,
ContentCommand,
TabNewCommand,
TabSwitchCommand,
TabCloseCommand,
WindowNewCommand,
@@ -50,7 +49,6 @@ import type {
IsCheckedCommand,
CountCommand,
BoundingBoxCommand,
StylesCommand,
TraceStartCommand,
TraceStopCommand,
HarStopCommand,
@@ -96,14 +94,6 @@ import type {
MultiSelectCommand,
WaitForDownloadCommand,
ResponseBodyCommand,
ScreencastStartCommand,
ScreencastStopCommand,
InputMouseCommand,
InputKeyboardCommand,
InputTouchCommand,
RecordingStartCommand,
RecordingStopCommand,
RecordingRestartCommand,
NavigateData,
ScreenshotData,
EvaluateData,
@@ -112,29 +102,9 @@ import type {
TabNewData,
TabSwitchData,
TabCloseData,
ScreencastStartData,
ScreencastStopData,
RecordingStartData,
RecordingStopData,
RecordingRestartData,
InputEventData,
StylesData,
} from './types.js';
import { successResponse, errorResponse } from './protocol.js';
// Callback for screencast frames - will be set by the daemon when streaming is active
let screencastFrameCallback: ((frame: ScreencastFrame) => void) | null = null;
/**
* Set the callback for screencast frames
* This is called by the daemon to set up frame streaming
*/
export function setScreencastFrameCallback(
callback: ((frame: ScreencastFrame) => void) | null
): void {
screencastFrameCallback = callback;
}
// Snapshot response type
interface SnapshotData {
snapshot: string;
@@ -143,9 +113,8 @@ interface SnapshotData {
/**
* Convert Playwright errors to AI-friendly messages
* @internal Exported for testing
*/
export function toAIFriendlyError(error: unknown, selector: string): Error {
function toAIFriendlyError(error: unknown, selector: string): Error {
const message = error instanceof Error ? error.message : String(error);
// Handle strict mode violation (multiple elements match)
@@ -160,24 +129,7 @@ export function toAIFriendlyError(error: unknown, selector: string): Error {
);
}
// Handle element not interactable (must be checked BEFORE timeout case)
// This includes cases where an overlay/modal blocks the element
if (message.includes('intercepts pointer events')) {
return new Error(
`Element "${selector}" is blocked by another element (likely a modal or overlay). ` +
`Try dismissing any modals/cookie banners first.`
);
}
// Handle element not visible
if (message.includes('not visible') && !message.includes('Timeout')) {
return new Error(
`Element "${selector}" is not visible. ` +
`Try scrolling it into view or check if it's hidden.`
);
}
// Handle element not found (timeout waiting for element)
// Handle element not found
if (
message.includes('waiting for') &&
(message.includes('to be visible') || message.includes('Timeout'))
@@ -188,6 +140,14 @@ export function toAIFriendlyError(error: unknown, selector: string): Error {
);
}
// Handle element not interactable
if (message.includes('intercepts pointer events') || message.includes('not visible')) {
return new Error(
`Element "${selector}" is not interactable (may be hidden or covered). ` +
`Try scrolling it into view or check if a modal/overlay is blocking it.`
);
}
// Return original error for unknown cases
return error instanceof Error ? error : new Error(message);
}
@@ -320,8 +280,6 @@ export async function executeCommand(command: Command, browser: BrowserManager):
return await handleCount(command, browser);
case 'boundingbox':
return await handleBoundingBox(command, browser);
case 'styles':
return await handleStyles(command, browser);
case 'video_start':
return await handleVideoStart(command, browser);
case 'video_stop':
@@ -428,22 +386,6 @@ export async function executeCommand(command: Command, browser: BrowserManager):
return await handleWaitForDownload(command, browser);
case 'responsebody':
return await handleResponseBody(command, browser);
case 'screencast_start':
return await handleScreencastStart(command, browser);
case 'screencast_stop':
return await handleScreencastStop(command, browser);
case 'input_mouse':
return await handleInputMouse(command, browser);
case 'input_keyboard':
return await handleInputKeyboard(command, browser);
case 'input_touch':
return await handleInputTouch(command, browser);
case 'recording_start':
return await handleRecordingStart(command, browser);
case 'recording_stop':
return await handleRecordingStop(command, browser);
case 'recording_restart':
return await handleRecordingRestart(command, browser);
default: {
// TypeScript narrows to never here, but we handle it for safety
const unknownCommand = command as { id: string; action: string };
@@ -469,12 +411,6 @@ async function handleNavigate(
browser: BrowserManager
): Promise<Response<NavigateData>> {
const page = browser.getPage();
// If headers are provided, set up scoped headers for this origin
if (command.headers && Object.keys(command.headers).length > 0) {
await browser.setScopedHeaders(command.url, command.headers);
}
await page.goto(command.url, {
waitUntil: command.waitUntil ?? 'load',
});
@@ -714,17 +650,10 @@ async function handleClose(
}
async function handleTabNew(
command: TabNewCommand,
command: Command & { action: 'tab_new' },
browser: BrowserManager
): Promise<Response<TabNewData>> {
const result = await browser.newTab();
// Navigate to URL if provided (same pattern as handleNavigate)
if (command.url) {
const page = browser.getPage();
await page.goto(command.url, { waitUntil: 'domcontentloaded' });
}
return successResponse(command.id, result);
}
@@ -743,7 +672,7 @@ async function handleTabSwitch(
command: TabSwitchCommand,
browser: BrowserManager
): Promise<Response<TabSwitchData>> {
const result = await browser.switchTo(command.index);
const result = browser.switchTo(command.index);
const page = browser.getPage();
return successResponse(command.id, {
...result,
@@ -1250,62 +1179,6 @@ async function handleBoundingBox(
return successResponse(command.id, { box });
}
async function handleStyles(
command: StylesCommand,
browser: BrowserManager
): Promise<Response<StylesData>> {
const page = browser.getPage();
// Shared extraction logic as a string to be eval'd in browser context
const extractStylesScript = `(function(el) {
const s = getComputedStyle(el);
const r = el.getBoundingClientRect();
return {
tag: el.tagName.toLowerCase(),
text: el.innerText?.trim().slice(0, 80) || null,
box: {
x: Math.round(r.x),
y: Math.round(r.y),
width: Math.round(r.width),
height: Math.round(r.height),
},
styles: {
fontSize: s.fontSize,
fontWeight: s.fontWeight,
fontFamily: s.fontFamily.split(',')[0].trim().replace(/"/g, ''),
color: s.color,
backgroundColor: s.backgroundColor,
borderRadius: s.borderRadius,
border: s.border !== 'none' && s.borderWidth !== '0px' ? s.border : null,
boxShadow: s.boxShadow !== 'none' ? s.boxShadow : null,
padding: s.padding,
},
};
})`;
// Check if it's a ref - single element
if (browser.isRef(command.selector)) {
const locator = browser.getLocator(command.selector);
const element = (await locator.evaluate((el, script) => {
const fn = eval(script);
return fn(el);
}, extractStylesScript)) as StylesData['elements'][0];
return successResponse(command.id, { elements: [element] });
}
// CSS selector - can match multiple elements
const elements = (await page.$$eval(
command.selector,
(els, script) => {
const fn = eval(script);
return els.map((el) => fn(el));
},
extractStylesScript
)) as StylesData['elements'];
return successResponse(command.id, { elements });
}
// Advanced handlers
async function handleVideoStart(
@@ -1504,8 +1377,8 @@ async function handleInputValue(
command: InputValueCommand,
browser: BrowserManager
): Promise<Response> {
const locator = browser.getLocator(command.selector);
const value = await locator.inputValue();
const page = browser.getPage();
const value = await page.locator(command.selector).inputValue();
return successResponse(command.id, { value });
}
@@ -1890,113 +1763,3 @@ async function handleResponseBody(
body: parsed,
});
}
// Screencast and input injection handlers
async function handleScreencastStart(
command: ScreencastStartCommand,
browser: BrowserManager
): Promise<Response<ScreencastStartData>> {
if (!screencastFrameCallback) {
throw new Error('Screencast frame callback not set. Start the streaming server first.');
}
await browser.startScreencast(screencastFrameCallback, {
format: command.format,
quality: command.quality,
maxWidth: command.maxWidth,
maxHeight: command.maxHeight,
everyNthFrame: command.everyNthFrame,
});
return successResponse(command.id, {
started: true,
format: command.format ?? 'jpeg',
quality: command.quality ?? 80,
});
}
async function handleScreencastStop(
command: ScreencastStopCommand,
browser: BrowserManager
): Promise<Response<ScreencastStopData>> {
await browser.stopScreencast();
return successResponse(command.id, { stopped: true });
}
async function handleInputMouse(
command: InputMouseCommand,
browser: BrowserManager
): Promise<Response<InputEventData>> {
await browser.injectMouseEvent({
type: command.type,
x: command.x,
y: command.y,
button: command.button,
clickCount: command.clickCount,
deltaX: command.deltaX,
deltaY: command.deltaY,
modifiers: command.modifiers,
});
return successResponse(command.id, { injected: true });
}
async function handleInputKeyboard(
command: InputKeyboardCommand,
browser: BrowserManager
): Promise<Response<InputEventData>> {
await browser.injectKeyboardEvent({
type: command.type,
key: command.key,
code: command.code,
text: command.text,
modifiers: command.modifiers,
});
return successResponse(command.id, { injected: true });
}
async function handleInputTouch(
command: InputTouchCommand,
browser: BrowserManager
): Promise<Response<InputEventData>> {
await browser.injectTouchEvent({
type: command.type,
touchPoints: command.touchPoints,
modifiers: command.modifiers,
});
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,
});
}
+1 -367
View File
@@ -1,6 +1,5 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { BrowserManager } from './browser.js';
import { chromium } from 'playwright-core';
describe('BrowserManager', () => {
let browser: BrowserManager;
@@ -23,35 +22,6 @@ describe('BrowserManager', () => {
const page = browser.getPage();
expect(page).toBeDefined();
});
it('should reject invalid executablePath', async () => {
const testBrowser = new BrowserManager();
await expect(
testBrowser.launch({
headless: true,
executablePath: '/nonexistent/path/to/chromium',
})
).rejects.toThrow();
});
it('should be no-op when relaunching with same options', async () => {
const browserInstance = browser.getBrowser();
await browser.launch({ id: 'test', action: 'launch', headless: true });
expect(browser.getBrowser()).toBe(browserInstance);
});
it('should reconnect when CDP port changes', async () => {
const newBrowser = new BrowserManager();
await newBrowser.launch({ id: 'test', action: 'launch', headless: true });
expect(newBrowser.getBrowser()).not.toBeNull();
await expect(
newBrowser.launch({ id: 'test', action: 'launch', cdpPort: 59999 })
).rejects.toThrow();
expect(newBrowser.getBrowser()).toBeNull();
await newBrowser.close();
});
});
describe('navigation', () => {
@@ -324,340 +294,4 @@ describe('BrowserManager', () => {
expect(h1).toBe('Example Domain');
});
});
describe('scoped headers', () => {
it('should register route for scoped headers', async () => {
// Test that setScopedHeaders doesn't throw and completes successfully
await browser.clearScopedHeaders();
await expect(
browser.setScopedHeaders('https://example.com', { 'X-Test': 'value' })
).resolves.not.toThrow();
await browser.clearScopedHeaders();
});
it('should handle full URL origin', async () => {
await browser.clearScopedHeaders();
await expect(
browser.setScopedHeaders('https://api.example.com/path', { Authorization: 'Bearer token' })
).resolves.not.toThrow();
await browser.clearScopedHeaders();
});
it('should handle hostname-only origin', async () => {
await browser.clearScopedHeaders();
await expect(
browser.setScopedHeaders('example.com', { 'X-Custom': 'value' })
).resolves.not.toThrow();
await browser.clearScopedHeaders();
});
it('should clear scoped headers for specific origin', async () => {
await browser.clearScopedHeaders();
await browser.setScopedHeaders('https://example.com', { 'X-Test': 'value' });
await expect(browser.clearScopedHeaders('https://example.com')).resolves.not.toThrow();
});
it('should clear all scoped headers', async () => {
await browser.setScopedHeaders('https://example.com', { 'X-Test-1': 'value1' });
await browser.setScopedHeaders('https://example.org', { 'X-Test-2': 'value2' });
await expect(browser.clearScopedHeaders()).resolves.not.toThrow();
});
it('should replace headers when called twice for same origin', async () => {
await browser.clearScopedHeaders();
await browser.setScopedHeaders('https://example.com', { 'X-First': 'first' });
// Second call should replace, not add
await expect(
browser.setScopedHeaders('https://example.com', { 'X-Second': 'second' })
).resolves.not.toThrow();
await browser.clearScopedHeaders();
});
it('should handle clearing non-existent origin gracefully', async () => {
await browser.clearScopedHeaders();
// Should not throw when clearing headers that were never set
await expect(browser.clearScopedHeaders('https://never-set.com')).resolves.not.toThrow();
});
});
describe('CDP session', () => {
it('should create CDP session on demand', async () => {
const cdp = await browser.getCDPSession();
expect(cdp).toBeDefined();
});
it('should reuse existing CDP session', async () => {
const cdp1 = await browser.getCDPSession();
const cdp2 = await browser.getCDPSession();
expect(cdp1).toBe(cdp2);
});
it('should filter out pages with empty URLs during CDP connection', async () => {
const mockBrowser = {
contexts: () => [
{
pages: () => [
{ url: () => 'http://example.com', on: vi.fn() },
{ url: () => '', on: vi.fn() }, // This page should be filtered out
{ url: () => 'http://anothersite.com', on: vi.fn() },
],
on: vi.fn(),
},
],
close: vi.fn(),
};
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
const cdpBrowser = new BrowserManager();
await cdpBrowser.launch({ cdpPort: 9222 });
// Should have 2 pages, not 3
expect(cdpBrowser.getPages().length).toBe(2);
// Verify that the empty URL page is not in the list
const urls = cdpBrowser.getPages().map((p) => p.url());
expect(urls).not.toContain('');
expect(urls).toContain('http://example.com');
spy.mockRestore();
});
});
describe('screencast', () => {
it('should report screencasting state correctly', () => {
expect(browser.isScreencasting()).toBe(false);
});
it('should start screencast', async () => {
const frames: Array<{ data: string }> = [];
await browser.startScreencast((frame) => {
frames.push(frame);
});
expect(browser.isScreencasting()).toBe(true);
// Wait a bit for at least one frame
await new Promise((resolve) => setTimeout(resolve, 1000));
await browser.stopScreencast();
expect(browser.isScreencasting()).toBe(false);
expect(frames.length).toBeGreaterThan(0);
});
it('should start screencast with custom options', async () => {
const frames: Array<{ data: string }> = [];
await browser.startScreencast(
(frame) => {
frames.push(frame);
},
{
format: 'png',
quality: 100,
maxWidth: 800,
maxHeight: 600,
everyNthFrame: 1,
}
);
expect(browser.isScreencasting()).toBe(true);
// Wait for a frame
await new Promise((resolve) => setTimeout(resolve, 200));
await browser.stopScreencast();
expect(frames.length).toBeGreaterThan(0);
});
it('should throw when starting screencast twice', async () => {
await browser.startScreencast(() => {});
await expect(browser.startScreencast(() => {})).rejects.toThrow('Screencast already active');
await browser.stopScreencast();
});
it('should handle stop when not screencasting', async () => {
// Should not throw
await expect(browser.stopScreencast()).resolves.not.toThrow();
});
});
describe('tab switch invalidates CDP session', () => {
// Clean up any extra tabs before each test
beforeEach(async () => {
// Close all tabs except the first one
const tabs = await browser.listTabs();
for (let i = tabs.length - 1; i > 0; i--) {
await browser.closeTab(i);
}
// Ensure we're on tab 0
await browser.switchTo(0);
// Stop any active screencast
if (browser.isScreencasting()) {
await browser.stopScreencast();
}
});
it('should not invalidate CDP when switching to same tab', async () => {
// Get CDP session for current tab
const cdp1 = await browser.getCDPSession();
// Switch to same tab - should NOT invalidate
await browser.switchTo(0);
// Should be the same session
const cdp2 = await browser.getCDPSession();
expect(cdp2).toBe(cdp1);
});
it('should invalidate CDP session on tab switch', async () => {
// Get CDP session for tab 0
const cdp1 = await browser.getCDPSession();
expect(cdp1).toBeDefined();
// Create new tab - this switches to the new tab automatically
await browser.newTab();
// Get CDP session - should be different since we're on a new page
const cdp2 = await browser.getCDPSession();
expect(cdp2).toBeDefined();
// Sessions should be different objects (different pages have different CDP sessions)
expect(cdp2).not.toBe(cdp1);
});
it('should stop screencast on tab switch', async () => {
// Start screencast on tab 0
await browser.startScreencast(() => {});
expect(browser.isScreencasting()).toBe(true);
// Create new tab and switch
await browser.newTab();
await browser.switchTo(1);
// Screencast should be stopped (it's page-specific)
expect(browser.isScreencasting()).toBe(false);
});
});
describe('input injection', () => {
it('should inject mouse move event', async () => {
await expect(
browser.injectMouseEvent({
type: 'mouseMoved',
x: 100,
y: 100,
})
).resolves.not.toThrow();
});
it('should inject mouse click events', async () => {
await expect(
browser.injectMouseEvent({
type: 'mousePressed',
x: 100,
y: 100,
button: 'left',
clickCount: 1,
})
).resolves.not.toThrow();
await expect(
browser.injectMouseEvent({
type: 'mouseReleased',
x: 100,
y: 100,
button: 'left',
})
).resolves.not.toThrow();
});
it('should inject mouse wheel event', async () => {
await expect(
browser.injectMouseEvent({
type: 'mouseWheel',
x: 100,
y: 100,
deltaX: 0,
deltaY: 100,
})
).resolves.not.toThrow();
});
it('should inject keyboard events', async () => {
await expect(
browser.injectKeyboardEvent({
type: 'keyDown',
key: 'a',
code: 'KeyA',
})
).resolves.not.toThrow();
await expect(
browser.injectKeyboardEvent({
type: 'keyUp',
key: 'a',
code: 'KeyA',
})
).resolves.not.toThrow();
});
it('should inject char event', async () => {
// CDP char events only accept single characters
await expect(
browser.injectKeyboardEvent({
type: 'char',
text: 'h',
})
).resolves.not.toThrow();
});
it('should inject keyboard with modifiers', async () => {
await expect(
browser.injectKeyboardEvent({
type: 'keyDown',
key: 'c',
code: 'KeyC',
modifiers: 2, // Ctrl
})
).resolves.not.toThrow();
});
it('should inject touch events', async () => {
await expect(
browser.injectTouchEvent({
type: 'touchStart',
touchPoints: [{ x: 100, y: 100 }],
})
).resolves.not.toThrow();
await expect(
browser.injectTouchEvent({
type: 'touchMove',
touchPoints: [{ x: 150, y: 150 }],
})
).resolves.not.toThrow();
await expect(
browser.injectTouchEvent({
type: 'touchEnd',
touchPoints: [],
})
).resolves.not.toThrow();
});
it('should inject multi-touch events', async () => {
await expect(
browser.injectTouchEvent({
type: 'touchStart',
touchPoints: [
{ x: 100, y: 100, id: 0 },
{ x: 200, y: 200, id: 1 },
],
})
).resolves.not.toThrow();
await expect(
browser.injectTouchEvent({
type: 'touchEnd',
touchPoints: [],
})
).resolves.not.toThrow();
});
});
});
+36 -733
View File
@@ -11,39 +11,10 @@ import {
type Request,
type Route,
type Locator,
type CDPSession,
type Video,
} from 'playwright-core';
import path from 'node:path';
import os from 'node:os';
import { existsSync, mkdirSync, rmSync } from 'node:fs';
import type { LaunchCommand } from './types.js';
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
// Screencast frame data from CDP
export interface ScreencastFrame {
data: string; // base64 encoded image
metadata: {
offsetTop: number;
pageScaleFactor: number;
deviceWidth: number;
deviceHeight: number;
scrollOffsetX: number;
scrollOffsetY: number;
timestamp?: number;
};
sessionId: number;
}
// Screencast options
export interface ScreencastOptions {
format?: 'jpeg' | 'png';
quality?: number; // 0-100, only for jpeg
maxWidth?: number;
maxHeight?: number;
everyNthFrame?: number;
}
interface TrackedRequest {
url: string;
method: string;
@@ -68,8 +39,6 @@ interface PageError {
*/
export class BrowserManager {
private browser: Browser | null = null;
private cdpPort: number | null = null;
private isPersistentContext: boolean = false;
private contexts: BrowserContext[] = [];
private pages: Page[] = [];
private activePageIndex: number = 0;
@@ -82,26 +51,12 @@ export class BrowserManager {
private isRecordingHar: boolean = false;
private refMap: RefMap = {};
private lastSnapshot: string = '';
private scopedHeaderRoutes: Map<string, (route: Route) => Promise<void>> = new Map();
// CDP session for screencast and input injection
private cdpSession: CDPSession | null = null;
private screencastActive: boolean = false;
private screencastSessionId: number = 0;
private frameCallback: ((frame: ScreencastFrame) => void) | null = null;
private screencastFrameHandler: ((params: any) => void) | null = null;
// Video recording (Playwright native)
private recordingContext: BrowserContext | null = null;
private recordingPage: Page | null = null;
private recordingOutputPath: string = '';
private recordingTempDir: string = '';
/**
* Check if browser is launched
*/
isLaunched(): boolean {
return this.browser !== null || this.isPersistentContext;
return this.browser !== null;
}
/**
@@ -484,7 +439,7 @@ export class BrowserManager {
}
/**
* Set extra HTTP headers (global - all requests)
* Set extra HTTP headers
*/
async setExtraHeaders(headers: Record<string, string>): Promise<void> {
const context = this.contexts[0];
@@ -493,76 +448,6 @@ export class BrowserManager {
}
}
/**
* Set scoped HTTP headers (only for requests matching the origin)
* Uses route interception to add headers only to matching requests
*/
async setScopedHeaders(origin: string, headers: Record<string, string>): Promise<void> {
const page = this.getPage();
// Build URL pattern from origin (e.g., "api.example.com" -> "**://api.example.com/**")
// Handle both full URLs and just hostnames
let urlPattern: string;
try {
const url = new URL(origin.startsWith('http') ? origin : `https://${origin}`);
// Match any protocol, the host, and any path
urlPattern = `**://${url.host}/**`;
} catch {
// If parsing fails, treat as hostname pattern
urlPattern = `**://${origin}/**`;
}
// Remove existing route for this origin if any
const existingHandler = this.scopedHeaderRoutes.get(urlPattern);
if (existingHandler) {
await page.unroute(urlPattern, existingHandler);
}
// Create handler that adds headers to matching requests
const handler = async (route: Route) => {
const requestHeaders = route.request().headers();
await route.continue({
headers: {
...requestHeaders,
...headers,
},
});
};
// Store and register the route
this.scopedHeaderRoutes.set(urlPattern, handler);
await page.route(urlPattern, handler);
}
/**
* Clear scoped headers for an origin (or all if no origin specified)
*/
async clearScopedHeaders(origin?: string): Promise<void> {
const page = this.getPage();
if (origin) {
let urlPattern: string;
try {
const url = new URL(origin.startsWith('http') ? origin : `https://${origin}`);
urlPattern = `**://${url.host}/**`;
} catch {
urlPattern = `**://${origin}/**`;
}
const handler = this.scopedHeaderRoutes.get(urlPattern);
if (handler) {
await page.unroute(urlPattern, handler);
this.scopedHeaderRoutes.delete(urlPattern);
}
} else {
// Clear all scoped header routes
for (const [pattern, handler] of this.scopedHeaderRoutes) {
await page.unroute(pattern, handler);
}
this.scopedHeaderRoutes.clear();
}
}
/**
* Start tracing
*/
@@ -617,158 +502,47 @@ export class BrowserManager {
return this.browser;
}
/**
* Check if an existing CDP connection is still alive
* by verifying we can access browser contexts and that at least one has pages
*/
private isCdpConnectionAlive(): boolean {
if (!this.browser) return false;
try {
const contexts = this.browser.contexts();
if (contexts.length === 0) return false;
return contexts.some((context) => context.pages().length > 0);
} catch {
return false;
}
}
/**
* Check if CDP connection needs to be re-established
*/
private needsCdpReconnect(cdpPort: number): boolean {
if (!this.browser?.isConnected()) return true;
if (this.cdpPort !== cdpPort) return true;
if (!this.isCdpConnectionAlive()) return true;
return false;
}
/**
* Launch the browser with the specified options
* If already launched, this is a no-op (browser stays open)
*/
async launch(options: LaunchCommand): Promise<void> {
const cdpPort = options.cdpPort;
const hasExtensions = !!options.extensions?.length;
if (hasExtensions && cdpPort) {
throw new Error('Extensions cannot be used with CDP connection');
}
if (this.isLaunched()) {
const needsRelaunch =
(!cdpPort && this.cdpPort !== null) || (!!cdpPort && this.needsCdpReconnect(cdpPort));
if (needsRelaunch) {
await this.close();
} else {
return;
}
}
if (cdpPort) {
await this.connectViaCDP(cdpPort);
// If already launched, don't relaunch
if (this.browser) {
return;
}
// Select browser type
const browserType = options.browser ?? 'chromium';
if (hasExtensions && browserType !== 'chromium') {
throw new Error('Extensions are only supported in Chromium');
}
const launcher =
browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium;
const viewport = options.viewport ?? { width: 1280, height: 720 };
let context: BrowserContext;
if (hasExtensions) {
const extPaths = options.extensions!.join(',');
const session = process.env.AGENT_BROWSER_SESSION || 'default';
context = await launcher.launchPersistentContext(
path.join(os.tmpdir(), `agent-browser-ext-${session}`),
{
headless: false,
executablePath: options.executablePath,
args: [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`],
viewport,
extraHTTPHeaders: options.headers,
...(options.proxy && { proxy: options.proxy }),
}
);
this.isPersistentContext = true;
} else {
this.browser = await launcher.launch({
headless: options.headless ?? true,
executablePath: options.executablePath,
});
this.cdpPort = null;
context = await this.browser.newContext({
viewport,
extraHTTPHeaders: options.headers,
...(options.proxy && { proxy: options.proxy }),
});
}
// Launch browser
this.browser = await launcher.launch({
headless: options.headless ?? true,
});
// Create context with viewport
const context = await this.browser.newContext({
viewport: options.viewport ?? { width: 1280, height: 720 },
});
// Set default timeout to 10 seconds (Playwright default is 30s)
context.setDefaultTimeout(10000);
context.setDefaultTimeout(60000);
this.contexts.push(context);
const page = context.pages()[0] ?? (await context.newPage());
// Create initial page
const page = await context.newPage();
this.pages.push(page);
this.activePageIndex = 0;
// Automatically start console and error tracking
this.setupPageTracking(page);
}
/**
* Connect to a running browser via CDP (Chrome DevTools Protocol)
*/
private async connectViaCDP(cdpPort: number | undefined): Promise<void> {
if (!cdpPort) {
throw new Error('cdpPort is required for CDP connection');
}
const browser = await chromium.connectOverCDP(`http://localhost:${cdpPort}`).catch(() => {
throw new Error(
`Failed to connect via CDP on port ${cdpPort}. ` +
`Make sure the app is running with --remote-debugging-port=${cdpPort}`
);
});
// Validate and set up state, cleaning up browser connection if anything fails
try {
const contexts = browser.contexts();
if (contexts.length === 0) {
throw new Error('No browser context found. Make sure the app has an open window.');
}
// Filter out pages with empty URLs, which can cause Playwright to hang
const allPages = contexts.flatMap((context) => context.pages()).filter((page) => page.url());
if (allPages.length === 0) {
throw new Error('No page found. Make sure the app has loaded content.');
}
// All validation passed - commit state
this.browser = browser;
this.cdpPort = cdpPort;
for (const context of contexts) {
this.contexts.push(context);
this.setupContextTracking(context);
}
for (const page of allPages) {
this.pages.push(page);
this.setupPageTracking(page);
}
this.activePageIndex = 0;
} catch (error) {
// Clean up browser connection if validation or setup failed
await browser.close().catch(() => {});
throw error;
}
}
/**
* Set up console, error, and close tracking for a page
* Set up console and error tracking for a page
*/
private setupPageTracking(page: Page): void {
page.on('console', (msg) => {
@@ -785,26 +559,6 @@ export class BrowserManager {
timestamp: Date.now(),
});
});
page.on('close', () => {
const index = this.pages.indexOf(page);
if (index !== -1) {
this.pages.splice(index, 1);
if (this.activePageIndex >= this.pages.length) {
this.activePageIndex = Math.max(0, this.pages.length - 1);
}
}
});
}
/**
* Set up tracking for new pages in a context (for CDP connections)
*/
private setupContextTracking(context: BrowserContext): void {
context.on('page', (page) => {
this.pages.push(page);
this.setupPageTracking(page);
});
}
/**
@@ -815,9 +569,6 @@ export class BrowserManager {
throw new Error('Browser not launched');
}
// Invalidate CDP session since we're switching to a new page
await this.invalidateCDPSession();
const context = this.contexts[0]; // Use first context for tabs
const page = await context.newPage();
this.pages.push(page);
@@ -843,7 +594,7 @@ export class BrowserManager {
const context = await this.browser.newContext({
viewport: viewport ?? { width: 1280, height: 720 },
});
context.setDefaultTimeout(60000);
context.setDefaultTimeout(10000);
this.contexts.push(context);
const page = await context.newPage();
@@ -856,36 +607,14 @@ export class BrowserManager {
return { index: this.activePageIndex, total: this.pages.length };
}
/**
* Invalidate the current CDP session (must be called before switching pages)
* This ensures screencast and input injection work correctly after tab switch
*/
private async invalidateCDPSession(): Promise<void> {
// Stop screencast if active (it's tied to the current page's CDP session)
if (this.screencastActive) {
await this.stopScreencast();
}
// Detach and clear the CDP session
if (this.cdpSession) {
await this.cdpSession.detach().catch(() => {});
this.cdpSession = null;
}
}
/**
* Switch to a specific tab/page by index
*/
async switchTo(index: number): Promise<{ index: number; url: string; title: string }> {
switchTo(index: number): { index: number; url: string; title: string } {
if (index < 0 || index >= this.pages.length) {
throw new Error(`Invalid tab index: ${index}. Available: 0-${this.pages.length - 1}`);
}
// Invalidate CDP session before switching (it's page-specific)
if (index !== this.activePageIndex) {
await this.invalidateCDPSession();
}
this.activePageIndex = index;
const page = this.pages[index];
@@ -910,11 +639,6 @@ export class BrowserManager {
throw new Error('Cannot close the last tab. Use "close" to close the browser.');
}
// If closing the active tab, invalidate CDP session first
if (targetIndex === this.activePageIndex) {
await this.invalidateCDPSession();
}
const page = this.pages[targetIndex];
await page.close();
this.pages.splice(targetIndex, 1);
@@ -944,448 +668,27 @@ export class BrowserManager {
return tabs;
}
/**
* Get or create a CDP session for the current page
* Only works with Chromium-based browsers
*/
async getCDPSession(): Promise<CDPSession> {
if (this.cdpSession) {
return this.cdpSession;
}
const page = this.getPage();
const context = page.context();
// Create a new CDP session attached to the page
this.cdpSession = await context.newCDPSession(page);
return this.cdpSession;
}
/**
* Check if screencast is currently active
*/
isScreencasting(): boolean {
return this.screencastActive;
}
/**
* Start screencast - streams viewport frames via CDP
* @param callback Function called for each frame
* @param options Screencast options
*/
async startScreencast(
callback: (frame: ScreencastFrame) => void,
options?: ScreencastOptions
): Promise<void> {
if (this.screencastActive) {
throw new Error('Screencast already active');
}
const cdp = await this.getCDPSession();
this.frameCallback = callback;
this.screencastActive = true;
// Create and store the frame handler so we can remove it later
this.screencastFrameHandler = async (params: any) => {
const frame: ScreencastFrame = {
data: params.data,
metadata: params.metadata,
sessionId: params.sessionId,
};
// Acknowledge the frame to receive the next one
await cdp.send('Page.screencastFrameAck', { sessionId: params.sessionId });
// Call the callback with the frame
if (this.frameCallback) {
this.frameCallback(frame);
}
};
// Listen for screencast frames
cdp.on('Page.screencastFrame', this.screencastFrameHandler);
// Start the screencast
await cdp.send('Page.startScreencast', {
format: options?.format ?? 'jpeg',
quality: options?.quality ?? 80,
maxWidth: options?.maxWidth ?? 1280,
maxHeight: options?.maxHeight ?? 720,
everyNthFrame: options?.everyNthFrame ?? 1,
});
}
/**
* Stop screencast
*/
async stopScreencast(): Promise<void> {
if (!this.screencastActive) {
return;
}
try {
const cdp = await this.getCDPSession();
await cdp.send('Page.stopScreencast');
// Remove the event listener to prevent accumulation
if (this.screencastFrameHandler) {
cdp.off('Page.screencastFrame', this.screencastFrameHandler);
}
} catch {
// Ignore errors when stopping
}
this.screencastActive = false;
this.frameCallback = null;
this.screencastFrameHandler = null;
}
/**
* Inject a mouse event via CDP
*/
async injectMouseEvent(params: {
type: 'mousePressed' | 'mouseReleased' | 'mouseMoved' | 'mouseWheel';
x: number;
y: number;
button?: 'left' | 'right' | 'middle' | 'none';
clickCount?: number;
deltaX?: number;
deltaY?: number;
modifiers?: number; // 1=Alt, 2=Ctrl, 4=Meta, 8=Shift
}): Promise<void> {
const cdp = await this.getCDPSession();
const cdpButton =
params.button === 'left'
? 'left'
: params.button === 'right'
? 'right'
: params.button === 'middle'
? 'middle'
: 'none';
await cdp.send('Input.dispatchMouseEvent', {
type: params.type,
x: params.x,
y: params.y,
button: cdpButton,
clickCount: params.clickCount ?? 1,
deltaX: params.deltaX ?? 0,
deltaY: params.deltaY ?? 0,
modifiers: params.modifiers ?? 0,
});
}
/**
* Inject a keyboard event via CDP
*/
async injectKeyboardEvent(params: {
type: 'keyDown' | 'keyUp' | 'char';
key?: string;
code?: string;
text?: string;
modifiers?: number; // 1=Alt, 2=Ctrl, 4=Meta, 8=Shift
}): Promise<void> {
const cdp = await this.getCDPSession();
await cdp.send('Input.dispatchKeyEvent', {
type: params.type,
key: params.key,
code: params.code,
text: params.text,
modifiers: params.modifiers ?? 0,
});
}
/**
* Inject touch event via CDP (for mobile emulation)
*/
async injectTouchEvent(params: {
type: 'touchStart' | 'touchEnd' | 'touchMove' | 'touchCancel';
touchPoints: Array<{ x: number; y: number; id?: number }>;
modifiers?: number;
}): Promise<void> {
const cdp = await this.getCDPSession();
await cdp.send('Input.dispatchTouchEvent', {
type: params.type,
touchPoints: params.touchPoints.map((tp, i) => ({
x: tp.x,
y: tp.y,
id: tp.id ?? i,
})),
modifiers: params.modifiers ?? 0,
});
}
/**
* Check if video recording is currently active
*/
isRecording(): boolean {
return this.recordingContext !== null;
}
/**
* Start recording to a video file using Playwright's native video recording.
* Creates a fresh browser context with video recording enabled.
* Automatically captures current URL and transfers cookies/storage if no URL provided.
*
* @param outputPath - Path to the output video file (will be .webm)
* @param url - Optional URL to navigate to (defaults to current page URL)
*/
async startRecording(outputPath: string, url?: string): Promise<void> {
if (this.recordingContext) {
throw new Error(
"Recording already in progress. Run 'record stop' first, or use 'record restart' to stop and start a new recording."
);
}
if (!this.browser) {
throw new Error('Browser not launched. Call launch first.');
}
// Check if output file already exists
if (existsSync(outputPath)) {
throw new Error(`Output file already exists: ${outputPath}`);
}
// Validate output path is .webm (Playwright native format)
if (!outputPath.endsWith('.webm')) {
throw new Error(
'Playwright native recording only supports WebM format. Please use a .webm extension.'
);
}
// Auto-capture current URL if none provided
const currentPage = this.pages.length > 0 ? this.pages[this.activePageIndex] : null;
const currentContext = this.contexts.length > 0 ? this.contexts[0] : null;
if (!url && currentPage) {
const currentUrl = currentPage.url();
if (currentUrl && currentUrl !== 'about:blank') {
url = currentUrl;
}
}
// Capture state from current context (cookies + storage)
let storageState:
| {
cookies: Array<{
name: string;
value: string;
domain: string;
path: string;
expires: number;
httpOnly: boolean;
secure: boolean;
sameSite: 'Strict' | 'Lax' | 'None';
}>;
origins: Array<{
origin: string;
localStorage: Array<{ name: string; value: string }>;
}>;
}
| undefined;
if (currentContext) {
try {
storageState = await currentContext.storageState();
} catch {
// Ignore errors - context might be closed or invalid
}
}
// Create a temp directory for video recording
const session = process.env.AGENT_BROWSER_SESSION || 'default';
this.recordingTempDir = path.join(
os.tmpdir(),
`agent-browser-recording-${session}-${Date.now()}`
);
mkdirSync(this.recordingTempDir, { recursive: true });
this.recordingOutputPath = outputPath;
// Create a new context with video recording enabled and restored state
const viewport = { width: 1280, height: 720 };
this.recordingContext = await this.browser.newContext({
viewport,
recordVideo: {
dir: this.recordingTempDir,
size: viewport,
},
storageState,
});
this.recordingContext.setDefaultTimeout(10000);
// Create a page in the recording context
this.recordingPage = await this.recordingContext.newPage();
// Add the recording context and page to our managed lists
this.contexts.push(this.recordingContext);
this.pages.push(this.recordingPage);
this.activePageIndex = this.pages.length - 1;
// Set up page tracking
this.setupPageTracking(this.recordingPage);
// Invalidate CDP session since we switched pages
await this.invalidateCDPSession();
// Navigate to URL if provided or captured
if (url) {
await this.recordingPage.goto(url, { waitUntil: 'load' });
}
}
/**
* Stop recording and save the video file
* @returns Recording result with path
*/
async stopRecording(): Promise<{ path: string; frames: number; error?: string }> {
if (!this.recordingContext || !this.recordingPage) {
return { path: '', frames: 0, error: 'No recording in progress' };
}
const outputPath = this.recordingOutputPath;
try {
// Get the video object before closing the page
const video = this.recordingPage.video();
// Remove recording page/context from our managed lists before closing
const pageIndex = this.pages.indexOf(this.recordingPage);
if (pageIndex !== -1) {
this.pages.splice(pageIndex, 1);
}
const contextIndex = this.contexts.indexOf(this.recordingContext);
if (contextIndex !== -1) {
this.contexts.splice(contextIndex, 1);
}
// Close the page to finalize the video
await this.recordingPage.close();
// Save the video to the desired output path
if (video) {
await video.saveAs(outputPath);
}
// Clean up temp directory
if (this.recordingTempDir) {
rmSync(this.recordingTempDir, { recursive: true, force: true });
}
// Close the recording context
await this.recordingContext.close();
// Reset recording state
this.recordingContext = null;
this.recordingPage = null;
this.recordingOutputPath = '';
this.recordingTempDir = '';
// Adjust active page index
if (this.pages.length > 0) {
this.activePageIndex = Math.min(this.activePageIndex, this.pages.length - 1);
} else {
this.activePageIndex = 0;
}
// Invalidate CDP session since we may have switched pages
await this.invalidateCDPSession();
return { path: outputPath, frames: 0 }; // Playwright doesn't expose frame count
} catch (error) {
// Clean up temp directory on error
if (this.recordingTempDir) {
rmSync(this.recordingTempDir, { recursive: true, force: true });
}
// Reset state on error
this.recordingContext = null;
this.recordingPage = null;
this.recordingOutputPath = '';
this.recordingTempDir = '';
const message = error instanceof Error ? error.message : String(error);
return { path: outputPath, frames: 0, error: message };
}
}
/**
* Restart recording - stops current recording (if any) and starts a new one.
* Convenience method that combines stopRecording and startRecording.
*
* @param outputPath - Path to the output video file (must be .webm)
* @param url - Optional URL to navigate to (defaults to current page URL)
* @returns Result from stopping the previous recording (if any)
*/
async restartRecording(
outputPath: string,
url?: string
): Promise<{ previousPath?: string; stopped: boolean }> {
let previousPath: string | undefined;
let stopped = false;
// Stop current recording if active
if (this.recordingContext) {
const result = await this.stopRecording();
previousPath = result.path;
stopped = true;
}
// Start new recording
await this.startRecording(outputPath, url);
return { previousPath, stopped };
}
/**
* Close the browser and clean up
*/
async close(): Promise<void> {
// Stop recording if active (saves video)
if (this.recordingContext) {
await this.stopRecording();
for (const page of this.pages) {
await page.close().catch(() => {});
}
// Stop screencast if active
if (this.screencastActive) {
await this.stopScreencast();
}
// Clean up CDP session
if (this.cdpSession) {
await this.cdpSession.detach().catch(() => {});
this.cdpSession = null;
}
// CDP: only disconnect, don't close external app's pages
if (this.cdpPort !== null) {
if (this.browser) {
await this.browser.close().catch(() => {});
this.browser = null;
}
} else {
// Regular browser: close everything
for (const page of this.pages) {
await page.close().catch(() => {});
}
for (const context of this.contexts) {
await context.close().catch(() => {});
}
if (this.browser) {
await this.browser.close().catch(() => {});
this.browser = null;
}
}
this.pages = [];
for (const context of this.contexts) {
await context.close().catch(() => {});
}
this.contexts = [];
this.cdpPort = null;
this.isPersistentContext = false;
if (this.browser) {
await this.browser.close().catch(() => {});
this.browser = null;
}
this.activePageIndex = 0;
this.refMap = {};
this.lastSnapshot = '';
this.frameCallback = null;
}
}
+2 -61
View File
@@ -5,7 +5,6 @@ import * as os from 'os';
import { BrowserManager } from './browser.js';
import { parseCommand, serializeResponse, errorResponse } from './protocol.js';
import { executeCommand } from './actions.js';
import { StreamServer } from './stream-server.js';
// Platform detection
const isWindows = process.platform === 'win32';
@@ -13,12 +12,6 @@ const isWindows = process.platform === 'win32';
// Session support - each session gets its own socket/pid
let currentSession = process.env.AGENT_BROWSER_SESSION || 'default';
// Stream server for browser preview
let streamServer: StreamServer | null = null;
// Default stream port (can be overridden with AGENT_BROWSER_STREAM_PORT)
const DEFAULT_STREAM_PORT = 9223;
/**
* Set the current session
*/
@@ -112,10 +105,8 @@ export function getConnectionInfo(
*/
export function cleanupSocket(session?: string): void {
const pidFile = getPidFile(session);
const streamPortFile = getStreamPortFile(session);
try {
if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile);
if (fs.existsSync(streamPortFile)) fs.unlinkSync(streamPortFile);
if (isWindows) {
const portFile = getPortFile(session);
if (fs.existsSync(portFile)) fs.unlinkSync(portFile);
@@ -128,41 +119,16 @@ export function cleanupSocket(session?: string): void {
}
}
/**
* Get the stream port file path
*/
export function getStreamPortFile(session?: string): string {
const sess = session ?? currentSession;
return path.join(os.tmpdir(), `agent-browser-${sess}.stream`);
}
/**
* Start the daemon server
* @param options.streamPort Port for WebSocket stream server (0 to disable)
*/
export async function startDaemon(options?: { streamPort?: number }): Promise<void> {
export async function startDaemon(): Promise<void> {
// Clean up any stale socket
cleanupSocket();
const browser = new BrowserManager();
let shuttingDown = false;
// Start stream server if port is specified (or use default if env var is set)
const streamPort =
options?.streamPort ??
(process.env.AGENT_BROWSER_STREAM_PORT
? parseInt(process.env.AGENT_BROWSER_STREAM_PORT, 10)
: 0);
if (streamPort > 0) {
streamServer = new StreamServer(browser, streamPort);
await streamServer.start();
// Write stream port to file for clients to discover
const streamPortFile = getStreamPortFile();
fs.writeFileSync(streamPortFile, streamPort.toString());
}
const server = net.createServer((socket) => {
let buffer = '';
@@ -192,18 +158,7 @@ export async function startDaemon(options?: { streamPort?: number }): Promise<vo
parseResult.command.action !== 'launch' &&
parseResult.command.action !== 'close'
) {
const extensions = process.env.AGENT_BROWSER_EXTENSIONS
? process.env.AGENT_BROWSER_EXTENSIONS.split(',')
.map((p) => p.trim())
.filter(Boolean)
: undefined;
await browser.launch({
id: 'auto',
action: 'launch',
headless: process.env.AGENT_BROWSER_HEADED !== '1',
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
extensions: extensions,
});
await browser.launch({ id: 'auto', action: 'launch', headless: true });
}
// Handle close command specially
@@ -267,20 +222,6 @@ export async function startDaemon(options?: { streamPort?: number }): Promise<vo
const shutdown = async () => {
if (shuttingDown) return;
shuttingDown = true;
// Stop stream server if running
if (streamServer) {
await streamServer.stop();
streamServer = null;
// Clean up stream port file
const streamPortFile = getStreamPortFile();
try {
if (fs.existsSync(streamPortFile)) fs.unlinkSync(streamPortFile);
} catch {
// Ignore cleanup errors
}
}
await browser.close();
server.close();
cleanupSocket();
-427
View File
@@ -15,22 +15,6 @@ describe('parseCommand', () => {
}
});
it('should parse navigate with headers', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'navigate',
url: 'https://example.com',
headers: { Authorization: 'Bearer token' },
})
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe('navigate');
expect(result.command.headers).toEqual({ Authorization: 'Bearer token' });
}
});
it('should reject navigate without url', () => {
const result = parseCommand(cmd({ id: '1', action: 'navigate' }));
expect(result.success).toBe(false);
@@ -389,14 +373,6 @@ describe('parseCommand', () => {
expect(result.success).toBe(true);
});
it('should parse tab_new with url', () => {
const result = parseCommand(cmd({ id: '1', action: 'tab_new', url: 'https://example.com' }));
expect(result.success).toBe(true);
if (result.success) {
expect((result.command as { url?: string }).url).toBe('https://example.com');
}
});
it('should parse tab_list', () => {
const result = parseCommand(cmd({ id: '1', action: 'tab_list' }));
expect(result.success).toBe(true);
@@ -485,24 +461,6 @@ describe('parseCommand', () => {
expect(result.command.headless).toBe(false);
}
});
it('should parse launch with cdpPort', () => {
const result = parseCommand(cmd({ id: '1', action: 'launch', cdpPort: 9222 }));
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.cdpPort).toBe(9222);
}
});
it('should reject launch with invalid cdpPort', () => {
const result = parseCommand(cmd({ id: '1', action: 'launch', cdpPort: -1 }));
expect(result.success).toBe(false);
});
it('should reject launch with non-numeric cdpPort', () => {
const result = parseCommand(cmd({ id: '1', action: 'launch', cdpPort: 'invalid' }));
expect(result.success).toBe(false);
});
});
describe('mouse actions', () => {
@@ -644,391 +602,6 @@ describe('parseCommand', () => {
});
});
describe('screencast', () => {
it('should parse screencast_start with defaults', () => {
const result = parseCommand(cmd({ id: '1', action: 'screencast_start' }));
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe('screencast_start');
}
});
it('should parse screencast_start with all options', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'screencast_start',
format: 'png',
quality: 90,
maxWidth: 1920,
maxHeight: 1080,
everyNthFrame: 2,
})
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.format).toBe('png');
expect(result.command.quality).toBe(90);
expect(result.command.maxWidth).toBe(1920);
expect(result.command.maxHeight).toBe(1080);
expect(result.command.everyNthFrame).toBe(2);
}
});
it('should reject screencast_start with invalid format', () => {
const result = parseCommand(cmd({ id: '1', action: 'screencast_start', format: 'gif' }));
expect(result.success).toBe(false);
});
it('should reject screencast_start with quality out of range', () => {
const result = parseCommand(cmd({ id: '1', action: 'screencast_start', quality: 150 }));
expect(result.success).toBe(false);
});
it('should reject screencast_start with negative maxWidth', () => {
const result = parseCommand(cmd({ id: '1', action: 'screencast_start', maxWidth: -100 }));
expect(result.success).toBe(false);
});
it('should parse screencast_stop', () => {
const result = parseCommand(cmd({ id: '1', action: 'screencast_stop' }));
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe('screencast_stop');
}
});
});
describe('input injection', () => {
describe('input_mouse', () => {
it('should parse mousePressed event', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'input_mouse',
type: 'mousePressed',
x: 100,
y: 200,
button: 'left',
})
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe('input_mouse');
expect(result.command.type).toBe('mousePressed');
expect(result.command.x).toBe(100);
expect(result.command.y).toBe(200);
expect(result.command.button).toBe('left');
}
});
it('should parse mouseReleased event', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'input_mouse',
type: 'mouseReleased',
x: 100,
y: 200,
})
);
expect(result.success).toBe(true);
});
it('should parse mouseMoved event', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'input_mouse',
type: 'mouseMoved',
x: 150,
y: 250,
})
);
expect(result.success).toBe(true);
});
it('should parse mouseWheel event with deltas', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'input_mouse',
type: 'mouseWheel',
x: 100,
y: 200,
deltaX: 0,
deltaY: 100,
})
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.deltaX).toBe(0);
expect(result.command.deltaY).toBe(100);
}
});
it('should parse mouse event with modifiers', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'input_mouse',
type: 'mousePressed',
x: 100,
y: 200,
modifiers: 6, // Ctrl + Meta
})
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.modifiers).toBe(6);
}
});
it('should parse mouse event with clickCount', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'input_mouse',
type: 'mousePressed',
x: 100,
y: 200,
clickCount: 2,
})
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.clickCount).toBe(2);
}
});
it('should reject input_mouse with invalid type', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'input_mouse',
type: 'invalid',
x: 100,
y: 200,
})
);
expect(result.success).toBe(false);
});
it('should reject input_mouse without x coordinate', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'input_mouse',
type: 'mousePressed',
y: 200,
})
);
expect(result.success).toBe(false);
});
it('should reject input_mouse without y coordinate', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'input_mouse',
type: 'mousePressed',
x: 100,
})
);
expect(result.success).toBe(false);
});
});
describe('input_keyboard', () => {
it('should parse keyDown event', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'input_keyboard',
type: 'keyDown',
key: 'Enter',
code: 'Enter',
})
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe('input_keyboard');
expect(result.command.type).toBe('keyDown');
expect(result.command.key).toBe('Enter');
expect(result.command.code).toBe('Enter');
}
});
it('should parse keyUp event', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'input_keyboard',
type: 'keyUp',
key: 'a',
})
);
expect(result.success).toBe(true);
});
it('should parse char event with text', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'input_keyboard',
type: 'char',
text: 'hello',
})
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.text).toBe('hello');
}
});
it('should parse keyboard event with modifiers', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'input_keyboard',
type: 'keyDown',
key: 'c',
modifiers: 2, // Ctrl
})
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.modifiers).toBe(2);
}
});
it('should reject input_keyboard with invalid type', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'input_keyboard',
type: 'invalid',
})
);
expect(result.success).toBe(false);
});
});
describe('input_touch', () => {
it('should parse touchStart event', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'input_touch',
type: 'touchStart',
touchPoints: [{ x: 100, y: 200 }],
})
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe('input_touch');
expect(result.command.type).toBe('touchStart');
expect(result.command.touchPoints).toHaveLength(1);
expect(result.command.touchPoints[0].x).toBe(100);
expect(result.command.touchPoints[0].y).toBe(200);
}
});
it('should parse touchEnd event', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'input_touch',
type: 'touchEnd',
touchPoints: [],
})
);
expect(result.success).toBe(true);
});
it('should parse touchMove event', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'input_touch',
type: 'touchMove',
touchPoints: [{ x: 150, y: 250 }],
})
);
expect(result.success).toBe(true);
});
it('should parse touchCancel event', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'input_touch',
type: 'touchCancel',
touchPoints: [],
})
);
expect(result.success).toBe(true);
});
it('should parse multi-touch event', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'input_touch',
type: 'touchStart',
touchPoints: [
{ x: 100, y: 200, id: 0 },
{ x: 300, y: 400, id: 1 },
],
})
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.touchPoints).toHaveLength(2);
}
});
it('should parse touch event with modifiers', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'input_touch',
type: 'touchStart',
touchPoints: [{ x: 100, y: 200 }],
modifiers: 8, // Shift
})
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.modifiers).toBe(8);
}
});
it('should reject input_touch with invalid type', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'input_touch',
type: 'invalid',
touchPoints: [],
})
);
expect(result.success).toBe(false);
});
it('should reject input_touch without touchPoints', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'input_touch',
type: 'touchStart',
})
);
expect(result.success).toBe(false);
});
});
});
describe('invalid commands', () => {
it('should reject unknown action', () => {
const result = parseCommand(cmd({ id: '1', action: 'unknown' }));
+1 -95
View File
@@ -18,25 +18,12 @@ const launchSchema = baseCommandSchema.extend({
})
.optional(),
browser: z.enum(['chromium', 'firefox', 'webkit']).optional(),
cdpPort: z.number().positive().optional(),
executablePath: z.string().optional(),
extensions: z.array(z.string()).optional(),
headers: z.record(z.string()).optional(),
proxy: z
.object({
server: z.string().min(1),
bypass: z.string().optional(),
username: z.string().optional(),
password: z.string().optional(),
})
.optional(),
});
const navigateSchema = baseCommandSchema.extend({
action: z.literal('navigate'),
url: z.string().min(1),
waitUntil: z.enum(['load', 'domcontentloaded', 'networkidle']).optional(),
headers: z.record(z.string()).optional(),
});
const clickSchema = baseCommandSchema.extend({
@@ -307,11 +294,6 @@ const boundingBoxSchema = baseCommandSchema.extend({
selector: z.string().min(1),
});
const stylesSchema = baseCommandSchema.extend({
action: z.literal('styles'),
selector: z.string().min(1),
});
const videoStartSchema = baseCommandSchema.extend({
action: z.literal('video_start'),
path: z.string().min(1),
@@ -321,23 +303,6 @@ const videoStopSchema = baseCommandSchema.extend({
action: z.literal('video_stop'),
});
// Recording schemas (Playwright native video recording)
const recordingStartSchema = baseCommandSchema.extend({
action: z.literal('recording_start'),
path: z.string().min(1),
url: z.string().min(1).optional(),
});
const recordingStopSchema = baseCommandSchema.extend({
action: z.literal('recording_stop'),
});
const recordingRestartSchema = baseCommandSchema.extend({
action: z.literal('recording_restart'),
path: z.string().min(1),
url: z.string().min(1).optional(),
});
const traceStartSchema = baseCommandSchema.extend({
action: z.literal('trace_start'),
screenshots: z.boolean().optional(),
@@ -619,55 +584,6 @@ const responseBodySchema = baseCommandSchema.extend({
timeout: z.number().positive().optional(),
});
// Screencast schemas for streaming browser viewport
const screencastStartSchema = baseCommandSchema.extend({
action: z.literal('screencast_start'),
format: z.enum(['jpeg', 'png']).optional(),
quality: z.number().min(0).max(100).optional(),
maxWidth: z.number().positive().optional(),
maxHeight: z.number().positive().optional(),
everyNthFrame: z.number().positive().optional(),
});
const screencastStopSchema = baseCommandSchema.extend({
action: z.literal('screencast_stop'),
});
// Input injection schemas for pair browsing
const inputMouseSchema = baseCommandSchema.extend({
action: z.literal('input_mouse'),
type: z.enum(['mousePressed', 'mouseReleased', 'mouseMoved', 'mouseWheel']),
x: z.number(),
y: z.number(),
button: z.enum(['left', 'right', 'middle', 'none']).optional(),
clickCount: z.number().positive().optional(),
deltaX: z.number().optional(),
deltaY: z.number().optional(),
modifiers: z.number().optional(),
});
const inputKeyboardSchema = baseCommandSchema.extend({
action: z.literal('input_keyboard'),
type: z.enum(['keyDown', 'keyUp', 'char']),
key: z.string().optional(),
code: z.string().optional(),
text: z.string().optional(),
modifiers: z.number().optional(),
});
const inputTouchSchema = baseCommandSchema.extend({
action: z.literal('input_touch'),
type: z.enum(['touchStart', 'touchEnd', 'touchMove', 'touchCancel']),
touchPoints: z.array(
z.object({
x: z.number(),
y: z.number(),
id: z.number().optional(),
})
),
modifiers: z.number().optional(),
});
const pressSchema = baseCommandSchema.extend({
action: z.literal('press'),
key: z.string().min(1),
@@ -676,7 +592,7 @@ const pressSchema = baseCommandSchema.extend({
const screenshotSchema = baseCommandSchema.extend({
action: z.literal('screenshot'),
path: z.string().nullable().optional(),
path: z.string().optional(),
fullPage: z.boolean().optional(),
selector: z.string().min(1).optional(),
format: z.enum(['png', 'jpeg']).optional(),
@@ -736,7 +652,6 @@ const closeSchema = baseCommandSchema.extend({
// Tab/Window schemas
const tabNewSchema = baseCommandSchema.extend({
action: z.literal('tab_new'),
url: z.string().min(1).optional(),
});
const tabListSchema = baseCommandSchema.extend({
@@ -826,12 +741,8 @@ const commandSchema = z.discriminatedUnion('action', [
isCheckedSchema,
countSchema,
boundingBoxSchema,
stylesSchema,
videoStartSchema,
videoStopSchema,
recordingStartSchema,
recordingStopSchema,
recordingRestartSchema,
traceStartSchema,
traceStopSchema,
harStartSchema,
@@ -883,11 +794,6 @@ const commandSchema = z.discriminatedUnion('action', [
multiSelectSchema,
waitForDownloadSchema,
responseBodySchema,
screencastStartSchema,
screencastStopSchema,
inputMouseSchema,
inputKeyboardSchema,
inputTouchSchema,
]);
// Parse result type
-364
View File
@@ -1,364 +0,0 @@
import { WebSocketServer, WebSocket } from 'ws';
import type { BrowserManager, ScreencastFrame } from './browser.js';
import { setScreencastFrameCallback } from './actions.js';
// Message types for WebSocket communication
export interface FrameMessage {
type: 'frame';
data: string; // base64 encoded image
metadata: {
offsetTop: number;
pageScaleFactor: number;
deviceWidth: number;
deviceHeight: number;
scrollOffsetX: number;
scrollOffsetY: number;
timestamp?: number;
};
}
export interface InputMouseMessage {
type: 'input_mouse';
eventType: 'mousePressed' | 'mouseReleased' | 'mouseMoved' | 'mouseWheel';
x: number;
y: number;
button?: 'left' | 'right' | 'middle' | 'none';
clickCount?: number;
deltaX?: number;
deltaY?: number;
modifiers?: number;
}
export interface InputKeyboardMessage {
type: 'input_keyboard';
eventType: 'keyDown' | 'keyUp' | 'char';
key?: string;
code?: string;
text?: string;
modifiers?: number;
}
export interface InputTouchMessage {
type: 'input_touch';
eventType: 'touchStart' | 'touchEnd' | 'touchMove' | 'touchCancel';
touchPoints: Array<{ x: number; y: number; id?: number }>;
modifiers?: number;
}
export interface StatusMessage {
type: 'status';
connected: boolean;
screencasting: boolean;
viewportWidth?: number;
viewportHeight?: number;
}
export interface ErrorMessage {
type: 'error';
message: string;
}
export type StreamMessage =
| FrameMessage
| InputMouseMessage
| InputKeyboardMessage
| InputTouchMessage
| StatusMessage
| ErrorMessage;
/**
* WebSocket server for streaming browser viewport and receiving input
*/
export class StreamServer {
private wss: WebSocketServer | null = null;
private clients: Set<WebSocket> = new Set();
private browser: BrowserManager;
private port: number;
private isScreencasting: boolean = false;
constructor(browser: BrowserManager, port: number = 9223) {
this.browser = browser;
this.port = port;
}
/**
* Start the WebSocket server
*/
start(): Promise<void> {
return new Promise((resolve, reject) => {
try {
this.wss = new WebSocketServer({ port: this.port });
this.wss.on('connection', (ws) => {
this.handleConnection(ws);
});
this.wss.on('error', (error) => {
console.error('[StreamServer] WebSocket error:', error);
reject(error);
});
this.wss.on('listening', () => {
console.log(`[StreamServer] Listening on port ${this.port}`);
// Set up the screencast frame callback
setScreencastFrameCallback((frame) => {
this.broadcastFrame(frame);
});
resolve();
});
} catch (error) {
reject(error);
}
});
}
/**
* Stop the WebSocket server
*/
async stop(): Promise<void> {
// Stop screencasting
if (this.isScreencasting) {
await this.stopScreencast();
}
// Clear the callback
setScreencastFrameCallback(null);
// Close all clients
for (const client of this.clients) {
client.close();
}
this.clients.clear();
// Close the server
if (this.wss) {
return new Promise((resolve) => {
this.wss!.close(() => {
this.wss = null;
resolve();
});
});
}
}
/**
* Handle a new WebSocket connection
*/
private handleConnection(ws: WebSocket): void {
console.log('[StreamServer] Client connected');
this.clients.add(ws);
// Send initial status
this.sendStatus(ws);
// Start screencasting if this is the first client
if (this.clients.size === 1 && !this.isScreencasting) {
this.startScreencast().catch((error) => {
console.error('[StreamServer] Failed to start screencast:', error);
this.sendError(ws, error.message);
});
}
// Handle messages from client
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString()) as StreamMessage;
this.handleMessage(message, ws);
} catch (error) {
console.error('[StreamServer] Failed to parse message:', error);
}
});
// Handle client disconnect
ws.on('close', () => {
console.log('[StreamServer] Client disconnected');
this.clients.delete(ws);
// Stop screencasting if no more clients
if (this.clients.size === 0 && this.isScreencasting) {
this.stopScreencast().catch((error) => {
console.error('[StreamServer] Failed to stop screencast:', error);
});
}
});
ws.on('error', (error) => {
console.error('[StreamServer] Client error:', error);
this.clients.delete(ws);
});
}
/**
* Handle incoming messages from clients
*/
private async handleMessage(message: StreamMessage, ws: WebSocket): Promise<void> {
try {
switch (message.type) {
case 'input_mouse':
await this.browser.injectMouseEvent({
type: message.eventType,
x: message.x,
y: message.y,
button: message.button,
clickCount: message.clickCount,
deltaX: message.deltaX,
deltaY: message.deltaY,
modifiers: message.modifiers,
});
break;
case 'input_keyboard':
await this.browser.injectKeyboardEvent({
type: message.eventType,
key: message.key,
code: message.code,
text: message.text,
modifiers: message.modifiers,
});
break;
case 'input_touch':
await this.browser.injectTouchEvent({
type: message.eventType,
touchPoints: message.touchPoints,
modifiers: message.modifiers,
});
break;
case 'status':
// Client is requesting status
this.sendStatus(ws);
break;
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.sendError(ws, errorMessage);
}
}
/**
* Broadcast a frame to all connected clients
*/
private broadcastFrame(frame: ScreencastFrame): void {
const message: FrameMessage = {
type: 'frame',
data: frame.data,
metadata: frame.metadata,
};
const payload = JSON.stringify(message);
for (const client of this.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(payload);
}
}
}
/**
* Send status to a client
*/
private sendStatus(ws: WebSocket): void {
let viewportWidth: number | undefined;
let viewportHeight: number | undefined;
try {
const page = this.browser.getPage();
const viewport = page.viewportSize();
viewportWidth = viewport?.width;
viewportHeight = viewport?.height;
} catch {
// Browser not launched yet
}
const message: StatusMessage = {
type: 'status',
connected: true,
screencasting: this.isScreencasting,
viewportWidth,
viewportHeight,
};
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
}
/**
* Send an error to a client
*/
private sendError(ws: WebSocket, errorMessage: string): void {
const message: ErrorMessage = {
type: 'error',
message: errorMessage,
};
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
}
/**
* Start screencasting
*/
private async startScreencast(): Promise<void> {
// Set flag immediately to prevent race conditions with concurrent calls
if (this.isScreencasting) return;
this.isScreencasting = true;
try {
// Check if browser is launched
if (!this.browser.isLaunched()) {
throw new Error('Browser not launched');
}
await this.browser.startScreencast((frame) => this.broadcastFrame(frame), {
format: 'jpeg',
quality: 80,
maxWidth: 1280,
maxHeight: 720,
everyNthFrame: 1,
});
// Notify all clients
for (const client of this.clients) {
this.sendStatus(client);
}
} catch (error) {
// Reset flag on failure so caller can retry
this.isScreencasting = false;
throw error;
}
}
/**
* Stop screencasting
*/
private async stopScreencast(): Promise<void> {
if (!this.isScreencasting) return;
await this.browser.stopScreencast();
this.isScreencasting = false;
// Notify all clients
for (const client of this.clients) {
this.sendStatus(client);
}
}
/**
* Get the port the server is running on
*/
getPort(): number {
return this.port;
}
/**
* Get the number of connected clients
*/
getClientCount(): number {
return this.clients.size;
}
}
+2 -143
View File
@@ -12,23 +12,12 @@ export interface LaunchCommand extends BaseCommand {
headless?: boolean;
viewport?: { width: number; height: number };
browser?: 'chromium' | 'firefox' | 'webkit';
headers?: Record<string, string>;
executablePath?: string;
cdpPort?: number;
extensions?: string[];
proxy?: {
server: string;
bypass?: string;
username?: string;
password?: string;
};
}
export interface NavigateCommand extends BaseCommand {
action: 'navigate';
url: string;
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
headers?: Record<string, string>;
}
export interface ClickCommand extends BaseCommand {
@@ -316,12 +305,6 @@ export interface BoundingBoxCommand extends BaseCommand {
selector: string;
}
// Computed styles
export interface StylesCommand extends BaseCommand {
action: 'styles';
selector: string;
}
// More semantic locators
export interface GetByAltTextCommand extends BaseCommand {
action: 'getbyalttext';
@@ -471,50 +454,7 @@ export interface ResponseBodyCommand extends BaseCommand {
timeout?: number;
}
// Screencast commands for streaming browser viewport
export interface ScreencastStartCommand extends BaseCommand {
action: 'screencast_start';
format?: 'jpeg' | 'png';
quality?: number; // 0-100, jpeg only
maxWidth?: number;
maxHeight?: number;
everyNthFrame?: number;
}
export interface ScreencastStopCommand extends BaseCommand {
action: 'screencast_stop';
}
// Input injection commands for pair browsing
export interface InputMouseCommand extends BaseCommand {
action: 'input_mouse';
type: 'mousePressed' | 'mouseReleased' | 'mouseMoved' | 'mouseWheel';
x: number;
y: number;
button?: 'left' | 'right' | 'middle' | 'none';
clickCount?: number;
deltaX?: number;
deltaY?: number;
modifiers?: number;
}
export interface InputKeyboardCommand extends BaseCommand {
action: 'input_keyboard';
type: 'keyDown' | 'keyUp' | 'char';
key?: string;
code?: string;
text?: string;
modifiers?: number;
}
export interface InputTouchCommand extends BaseCommand {
action: 'input_touch';
type: 'touchStart' | 'touchEnd' | 'touchMove' | 'touchCancel';
touchPoints: Array<{ x: number; y: number; id?: number }>;
modifiers?: number;
}
// Video recording (Playwright native - requires launch-time setup)
// Video recording
export interface VideoStartCommand extends BaseCommand {
action: 'video_start';
path: string;
@@ -524,23 +464,6 @@ export interface VideoStopCommand extends BaseCommand {
action: 'video_stop';
}
// Screen recording (Playwright native - creates fresh recording context)
export interface RecordingStartCommand extends BaseCommand {
action: 'recording_start';
path: string;
url?: string;
}
export interface RecordingStopCommand extends BaseCommand {
action: 'recording_stop';
}
export interface RecordingRestartCommand extends BaseCommand {
action: 'recording_restart';
path: string;
url?: string;
}
// Tracing
export interface TraceStartCommand extends BaseCommand {
action: 'trace_start';
@@ -778,7 +701,6 @@ export interface CloseCommand extends BaseCommand {
// Tab/Window commands
export interface TabNewCommand extends BaseCommand {
action: 'tab_new';
url?: string;
}
export interface TabListCommand extends BaseCommand {
@@ -863,12 +785,8 @@ export type Command =
| IsCheckedCommand
| CountCommand
| BoundingBoxCommand
| StylesCommand
| VideoStartCommand
| VideoStopCommand
| RecordingStartCommand
| RecordingStopCommand
| RecordingRestartCommand
| TraceStartCommand
| TraceStopCommand
| HarStartCommand
@@ -919,12 +837,7 @@ export type Command =
| InsertTextCommand
| MultiSelectCommand
| WaitForDownloadCommand
| ResponseBodyCommand
| ScreencastStartCommand
| ScreencastStopCommand
| InputMouseCommand
| InputKeyboardCommand
| InputTouchCommand;
| ResponseBodyCommand;
// Response types
export interface SuccessResponse<T = unknown> {
@@ -992,60 +905,6 @@ export interface TabCloseData {
remaining: number;
}
export interface ScreencastStartData {
started: boolean;
format: string;
quality: number;
}
export interface ScreencastStopData {
stopped: boolean;
}
export interface RecordingStartData {
started: boolean;
path: string;
}
export interface RecordingStopData {
path: string;
frames: number;
error?: string;
}
export interface RecordingRestartData {
started: boolean;
path: string;
previousPath?: string;
stopped: boolean;
}
export interface InputEventData {
injected: boolean;
}
// Element styles data
export interface ElementStyleInfo {
tag: string;
text: string | null;
box: { x: number; y: number; width: number; height: number };
styles: {
fontSize: string;
fontWeight: string;
fontFamily: string;
color: string;
backgroundColor: string;
borderRadius: string;
border: string | null;
boxShadow: string | null;
padding: string;
};
}
export interface StylesData {
elements: ElementStyleInfo[];
}
// Browser state
export interface BrowserState {
browser: Browser | null;
-85
View File
@@ -1,85 +0,0 @@
/**
* Integration test for @sparticuz/chromium compatibility
* This tests the executablePath option with a serverless-optimized Chromium build
*
* Note: @sparticuz/chromium only works on Linux (designed for AWS Lambda).
* This test will skip on non-Linux platforms.
*/
import { describe, it, expect, afterAll } from 'vitest';
import { BrowserManager } from '../src/browser.js';
import * as os from 'os';
const isLinux = os.platform() === 'linux';
// Only run if @sparticuz/chromium is available AND we're on Linux
const canRunTest = await (async () => {
if (!isLinux) {
console.log('Skipping @sparticuz/chromium test: only runs on Linux');
return false;
}
try {
await import('@sparticuz/chromium');
return true;
} catch {
console.log('Skipping @sparticuz/chromium test: package not installed');
return false;
}
})();
describe.skipIf(!canRunTest)('Serverless Chromium Integration', () => {
let browser: BrowserManager;
let chromiumPath: string;
it('should get executable path from @sparticuz/chromium', async () => {
const chromium = await import('@sparticuz/chromium');
chromiumPath = await chromium.default.executablePath();
expect(chromiumPath).toBeTruthy();
expect(typeof chromiumPath).toBe('string');
console.log('Chromium executable path:', chromiumPath);
});
it('should launch browser with custom executablePath', async () => {
const chromium = await import('@sparticuz/chromium');
chromiumPath = await chromium.default.executablePath();
browser = new BrowserManager();
await browser.launch({
headless: true,
executablePath: chromiumPath,
});
expect(browser.isLaunched()).toBe(true);
});
it('should navigate to a page', async () => {
const page = browser.getPage();
await page.goto('https://example.com');
expect(page.url()).toBe('https://example.com/');
});
it('should get page title', async () => {
const page = browser.getPage();
const title = await page.title();
expect(title).toBe('Example Domain');
});
it('should take snapshot with refs', async () => {
const { tree, refs } = await browser.getSnapshot();
expect(tree).toContain('Example Domain');
expect(typeof refs).toBe('object');
expect(Object.keys(refs).length).toBeGreaterThan(0);
});
it('should take screenshot', async () => {
const page = browser.getPage();
const buffer = await page.screenshot();
expect(buffer).toBeInstanceOf(Buffer);
expect(buffer.length).toBeGreaterThan(0);
});
afterAll(async () => {
if (browser?.isLaunched()) {
await browser.close();
}
});
});
+1 -1
View File
@@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
include: ['src/**/*.test.ts'],
testTimeout: 30000,
},
});