Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
068fc74a9c | ||
|
|
4f6fd8ec5c | ||
|
|
4e5ff20078 | ||
|
|
03e266ee38 | ||
|
|
8c412197ad | ||
|
|
3cd0ab468f | ||
|
|
a4fcc1c198 | ||
|
|
574037080c | ||
|
|
278466764b | ||
|
|
f2878c750d | ||
|
|
bc0b99c374 |
@@ -83,3 +83,106 @@ 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
|
||||
|
||||
@@ -55,13 +55,13 @@ agent-browser find role button click --name "Submit"
|
||||
### Core Commands
|
||||
|
||||
```bash
|
||||
agent-browser open <url> # Navigate to URL
|
||||
agent-browser open <url> # Navigate to URL (aliases: goto, navigate)
|
||||
agent-browser click <sel> # Click element
|
||||
agent-browser dblclick <sel> # Double-click element
|
||||
agent-browser focus <sel> # Focus element
|
||||
agent-browser type <sel> <text> # Type into element
|
||||
agent-browser fill <sel> <text> # Clear and fill
|
||||
agent-browser press <key> # Press key (Enter, Tab, Control+a)
|
||||
agent-browser press <key> # Press key (Enter, Tab, Control+a) (alias: key)
|
||||
agent-browser keydown <key> # Hold key down
|
||||
agent-browser keyup <key> # Release key
|
||||
agent-browser hover <sel> # Hover element
|
||||
@@ -69,14 +69,14 @@ 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 scrollintoview <sel> # Scroll element into view
|
||||
agent-browser scrollintoview <sel> # Scroll element into view (alias: scrollinto)
|
||||
agent-browser drag <src> <tgt> # Drag and drop
|
||||
agent-browser upload <sel> <files> # Upload files
|
||||
agent-browser screenshot [path] # Take screenshot (--full for full page)
|
||||
agent-browser pdf <path> # Save as PDF
|
||||
agent-browser snapshot # Accessibility tree with refs (best for AI)
|
||||
agent-browser eval <js> # Run JavaScript
|
||||
agent-browser close # Close browser
|
||||
agent-browser close # Close browser (aliases: quit, exit)
|
||||
```
|
||||
|
||||
### Get Info
|
||||
@@ -129,9 +129,9 @@ agent-browser find nth 2 "a" text
|
||||
### Wait
|
||||
|
||||
```bash
|
||||
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 <selector> # Wait for element to be visible
|
||||
agent-browser wait <ms> # Wait for time (milliseconds)
|
||||
agent-browser wait --text "Welcome" # Wait for text to appear
|
||||
agent-browser wait --url "**/dash" # Wait for URL pattern
|
||||
agent-browser wait --load networkidle # Wait for load state
|
||||
agent-browser wait --fn "window.ready === true" # Wait for JS condition
|
||||
@@ -253,6 +253,10 @@ 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
|
||||
@@ -289,6 +293,8 @@ 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 |
|
||||
@@ -383,6 +389,74 @@ 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
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
agent-browser uses a client-daemon architecture:
|
||||
@@ -393,15 +467,17 @@ agent-browser uses a client-daemon architecture:
|
||||
|
||||
The daemon starts automatically on first command and persists between commands for fast subsequent operations.
|
||||
|
||||
**Browser Engine:** Uses Chromium by default. The daemon also supports Firefox and WebKit via the Playwright protocol.
|
||||
|
||||
## Platforms
|
||||
|
||||
| Platform | Binary | Fallback |
|
||||
|----------|--------|----------|
|
||||
| macOS ARM64 | ✅ Native Rust | Node.js |
|
||||
| macOS x64 | ✅ Native Rust | Node.js |
|
||||
| Linux ARM64 | ✅ Native Rust | Node.js |
|
||||
| Linux x64 | ✅ Native Rust | Node.js |
|
||||
| Windows | - | Node.js |
|
||||
| macOS ARM64 | Native Rust | Node.js |
|
||||
| macOS x64 | Native Rust | Node.js |
|
||||
| Linux ARM64 | Native Rust | Node.js |
|
||||
| Linux x64 | Native Rust | Node.js |
|
||||
| Windows x64 | Native Rust | Node.js |
|
||||
|
||||
## Usage with AI Agents
|
||||
|
||||
@@ -436,15 +512,15 @@ Core workflow:
|
||||
For Claude Code, a [skill](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) provides richer context:
|
||||
|
||||
```bash
|
||||
cp -r node_modules/agent-browser/skills/browsing-web .claude/skills/
|
||||
cp -r node_modules/agent-browser/skills/agent-browser .claude/skills/
|
||||
```
|
||||
|
||||
Or download:
|
||||
|
||||
```bash
|
||||
mkdir -p .claude/skills/browsing-web
|
||||
curl -o .claude/skills/browsing-web/SKILL.md \
|
||||
https://raw.githubusercontent.com/vercel-labs/agent-browser/main/skills/browsing-web/SKILL.md
|
||||
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
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "agent-browser"
|
||||
version = "0.4.0"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"serde",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-browser"
|
||||
version = "0.4.0"
|
||||
version = "0.4.3"
|
||||
edition = "2021"
|
||||
description = "Fast browser automation CLI for AI agents"
|
||||
license = "Apache-2.0"
|
||||
|
||||
+182
-3
@@ -80,7 +80,14 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
} else {
|
||||
format!("https://{}", url)
|
||||
};
|
||||
Ok(json!({ "id": id, "action": "navigate", "url": 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)
|
||||
}
|
||||
"back" => Ok(json!({ "id": id, "action": "back" })),
|
||||
"forward" => Ok(json!({ "id": id, "action": "forward" })),
|
||||
@@ -212,6 +219,44 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
|
||||
// === Wait ===
|
||||
"wait" => {
|
||||
// Check for --url flag: wait --url "**/dashboard"
|
||||
if let Some(idx) = rest.iter().position(|&s| s == "--url" || s == "-u") {
|
||||
let url = rest.get(idx + 1).ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "wait --url".to_string(),
|
||||
usage: "wait --url <pattern>",
|
||||
})?;
|
||||
return Ok(json!({ "id": id, "action": "waitforurl", "url": url }));
|
||||
}
|
||||
|
||||
// Check for --load flag: wait --load networkidle
|
||||
if let Some(idx) = rest.iter().position(|&s| s == "--load" || s == "-l") {
|
||||
let state = rest.get(idx + 1).ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "wait --load".to_string(),
|
||||
usage: "wait --load <state>",
|
||||
})?;
|
||||
return Ok(json!({ "id": id, "action": "waitforloadstate", "state": state }));
|
||||
}
|
||||
|
||||
// Check for --fn flag: wait --fn "window.ready === true"
|
||||
if let Some(idx) = rest.iter().position(|&s| s == "--fn" || s == "-f") {
|
||||
let expr = rest.get(idx + 1).ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "wait --fn".to_string(),
|
||||
usage: "wait --fn <expression>",
|
||||
})?;
|
||||
return Ok(json!({ "id": id, "action": "waitforfunction", "expression": expr }));
|
||||
}
|
||||
|
||||
// Check for --text flag: wait --text "Welcome"
|
||||
if let Some(idx) = rest.iter().position(|&s| s == "--text" || s == "-t") {
|
||||
let text = rest.get(idx + 1).ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "wait --text".to_string(),
|
||||
usage: "wait --text <text>",
|
||||
})?;
|
||||
// Use getByText locator to wait for text to appear
|
||||
return Ok(json!({ "id": id, "action": "wait", "selector": format!("text={}", text) }));
|
||||
}
|
||||
|
||||
// Default: selector or timeout
|
||||
if let Some(arg) = rest.get(0) {
|
||||
if arg.parse::<u64>().is_ok() {
|
||||
Ok(json!({ "id": id, "action": "wait", "timeout": arg.parse::<u64>().unwrap() }))
|
||||
@@ -221,7 +266,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
} else {
|
||||
Err(ParseError::MissingArguments {
|
||||
context: "wait".to_string(),
|
||||
usage: "wait <selector|ms>",
|
||||
usage: "wait <selector|ms|--url|--load|--fn|--text>",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -728,7 +773,13 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
context: "set headers".to_string(),
|
||||
usage: "set headers <json>",
|
||||
})?;
|
||||
Ok(json!({ "id": id, "action": "headers", "headers": 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 }))
|
||||
}
|
||||
Some("credentials") | Some("auth") => {
|
||||
let user = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
||||
@@ -848,6 +899,8 @@ mod tests {
|
||||
full: false,
|
||||
headed: false,
|
||||
debug: false,
|
||||
headers: None,
|
||||
executable_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -974,6 +1027,81 @@ 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();
|
||||
@@ -1090,6 +1218,57 @@ mod tests {
|
||||
assert_eq!(cmd["maxDepth"], 3);
|
||||
}
|
||||
|
||||
// === Wait ===
|
||||
|
||||
#[test]
|
||||
fn test_wait_selector() {
|
||||
let cmd = parse_command(&args("wait #element"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "wait");
|
||||
assert_eq!(cmd["selector"], "#element");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_timeout() {
|
||||
let cmd = parse_command(&args("wait 5000"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "wait");
|
||||
assert_eq!(cmd["timeout"], 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_url() {
|
||||
let cmd = parse_command(&args("wait --url **/dashboard"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "waitforurl");
|
||||
assert_eq!(cmd["url"], "**/dashboard");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_load() {
|
||||
let cmd = parse_command(&args("wait --load networkidle"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "waitforloadstate");
|
||||
assert_eq!(cmd["state"], "networkidle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_load_missing_state() {
|
||||
let result = parse_command(&args("wait --load"), &default_flags());
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), ParseError::MissingArguments { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_fn() {
|
||||
let cmd = parse_command(&args("wait --fn window.ready"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "waitforfunction");
|
||||
assert_eq!(cmd["expression"], "window.ready");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_text() {
|
||||
let cmd = parse_command(&args("wait --text Welcome"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "wait");
|
||||
assert_eq!(cmd["selector"], "text=Welcome");
|
||||
}
|
||||
|
||||
// === Unknown command ===
|
||||
|
||||
#[test]
|
||||
|
||||
+24
-5
@@ -153,9 +153,15 @@ fn daemon_ready(session: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> {
|
||||
/// 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>) -> Result<DaemonResult, String> {
|
||||
if is_daemon_running(session) && daemon_ready(session) {
|
||||
return Ok(());
|
||||
return Ok(DaemonResult { already_running: true });
|
||||
}
|
||||
|
||||
let exe_path = env::current_exe().map_err(|e| e.to_string())?;
|
||||
@@ -186,6 +192,10 @@ pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> {
|
||||
cmd.env("AGENT_BROWSER_HEADED", "1");
|
||||
}
|
||||
|
||||
if let Some(path) = executable_path {
|
||||
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
|
||||
}
|
||||
|
||||
// Create new process group and session to fully detach
|
||||
unsafe {
|
||||
cmd.pre_exec(|| {
|
||||
@@ -206,8 +216,13 @@ pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> {
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
let mut cmd = Command::new("node");
|
||||
cmd.arg(daemon_path)
|
||||
// On Windows, use cmd.exe to run node to ensure proper PATH resolution.
|
||||
// This handles cases where node.exe isn't directly in PATH but node.cmd is.
|
||||
// Pass the entire command as a single string to /c to handle paths with spaces.
|
||||
let cmd_string = format!("node \"{}\"", daemon_path.display());
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.arg("/c")
|
||||
.arg(&cmd_string)
|
||||
.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session);
|
||||
|
||||
@@ -215,6 +230,10 @@ pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> {
|
||||
cmd.env("AGENT_BROWSER_HEADED", "1");
|
||||
}
|
||||
|
||||
if let Some(path) = executable_path {
|
||||
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
|
||||
}
|
||||
|
||||
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||
@@ -229,7 +248,7 @@ pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> {
|
||||
|
||||
for _ in 0..50 {
|
||||
if daemon_ready(session) {
|
||||
return Ok(());
|
||||
return Ok(DaemonResult { already_running: false });
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
+127
-1
@@ -6,6 +6,8 @@ pub struct Flags {
|
||||
pub headed: bool,
|
||||
pub debug: bool,
|
||||
pub session: String,
|
||||
pub headers: Option<String>,
|
||||
pub executable_path: Option<String>,
|
||||
}
|
||||
|
||||
pub fn parse_flags(args: &[String]) -> Flags {
|
||||
@@ -15,6 +17,8 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
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(),
|
||||
};
|
||||
|
||||
let mut i = 0;
|
||||
@@ -30,6 +34,18 @@ 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;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
@@ -43,13 +59,15 @@ 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"];
|
||||
|
||||
for arg in args.iter() {
|
||||
if skip_next {
|
||||
skip_next = false;
|
||||
continue;
|
||||
}
|
||||
if arg == "--session" {
|
||||
if GLOBAL_FLAGS_WITH_VALUE.contains(&arg.as_str()) {
|
||||
skip_next = true;
|
||||
continue;
|
||||
}
|
||||
@@ -61,3 +79,111 @@ 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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,16 @@ pub fn run_install(with_deps: bool) {
|
||||
}
|
||||
|
||||
println!("\x1b[36mInstalling Chromium browser...\x1b[0m");
|
||||
|
||||
// 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))]
|
||||
let status = Command::new("npx")
|
||||
.args(["playwright", "install", "chromium"])
|
||||
.status();
|
||||
|
||||
+25
-3
@@ -21,7 +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_help, print_response};
|
||||
use output::{print_command_help, print_help, print_response};
|
||||
|
||||
fn run_session(args: &[String], session: &str, json_mode: bool) {
|
||||
let subcommand = args.get(1).map(|s| s.as_str());
|
||||
@@ -98,7 +98,19 @@ fn main() {
|
||||
let flags = parse_flags(&args);
|
||||
let clean = clean_args(&args);
|
||||
|
||||
if clean.is_empty() || args.iter().any(|a| a == "--help" || a == "-h") {
|
||||
let has_help = args.iter().any(|a| a == "--help" || a == "-h");
|
||||
|
||||
if clean.is_empty() {
|
||||
print_help();
|
||||
return;
|
||||
}
|
||||
|
||||
if has_help {
|
||||
if let Some(cmd) = clean.get(0) {
|
||||
if print_command_help(cmd) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
print_help();
|
||||
return;
|
||||
}
|
||||
@@ -137,7 +149,9 @@ fn main() {
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = ensure_daemon(&flags.session, flags.headed) {
|
||||
let daemon_result = match ensure_daemon(&flags.session, flags.headed, flags.executable_path.as_deref()) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
if flags.json {
|
||||
println!(r#"{{"success":false,"error":"{}"}}"#, e);
|
||||
} else {
|
||||
@@ -145,6 +159,14 @@ fn main() {
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Warn if executable_path was specified but daemon was already running
|
||||
if daemon_result.already_running && flags.executable_path.is_some() {
|
||||
if !flags.json {
|
||||
eprintln!("\x1b[33m⚠\x1b[0m --executable-path ignored: daemon already running. Use 'agent-browser close' first to restart with new path.");
|
||||
}
|
||||
}
|
||||
|
||||
// If --headed flag is set, send launch command first to switch to headed mode
|
||||
if flags.headed {
|
||||
|
||||
@@ -145,6 +145,964 @@ pub fn print_response(resp: &Response, json_mode: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Print command-specific help. Returns true if help was printed, false if command unknown.
|
||||
pub fn print_command_help(command: &str) -> bool {
|
||||
let help = match command {
|
||||
// === Navigation ===
|
||||
"open" | "goto" | "navigate" => r##"
|
||||
agent-browser open - Navigate to a URL
|
||||
|
||||
Usage: agent-browser open <url>
|
||||
|
||||
Navigates the browser to the specified URL. If no protocol is provided,
|
||||
https:// is automatically prepended.
|
||||
|
||||
Aliases: goto, navigate
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
--headers <json> Set HTTP headers (scoped to this origin)
|
||||
--headed Show browser window
|
||||
|
||||
Examples:
|
||||
agent-browser open example.com
|
||||
agent-browser open https://github.com
|
||||
agent-browser open localhost:3000
|
||||
agent-browser open api.example.com --headers '{"Authorization": "Bearer token"}'
|
||||
# ^ Headers only sent to api.example.com, not other domains
|
||||
"##,
|
||||
"back" => r##"
|
||||
agent-browser back - Navigate back in history
|
||||
|
||||
Usage: agent-browser back
|
||||
|
||||
Goes back one page in the browser history, equivalent to clicking
|
||||
the browser's back button.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser back
|
||||
"##,
|
||||
"forward" => r##"
|
||||
agent-browser forward - Navigate forward in history
|
||||
|
||||
Usage: agent-browser forward
|
||||
|
||||
Goes forward one page in the browser history, equivalent to clicking
|
||||
the browser's forward button.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser forward
|
||||
"##,
|
||||
"reload" => r##"
|
||||
agent-browser reload - Reload the current page
|
||||
|
||||
Usage: agent-browser reload
|
||||
|
||||
Reloads the current page, equivalent to pressing F5 or clicking
|
||||
the browser's reload button.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser reload
|
||||
"##,
|
||||
|
||||
// === Core Actions ===
|
||||
"click" => r##"
|
||||
agent-browser click - Click an element
|
||||
|
||||
Usage: agent-browser click <selector>
|
||||
|
||||
Clicks on the specified element. The selector can be a CSS selector,
|
||||
XPath, or an element reference from snapshot (e.g., @e1).
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser click "#submit-button"
|
||||
agent-browser click @e1
|
||||
agent-browser click "button.primary"
|
||||
agent-browser click "//button[@type='submit']"
|
||||
"##,
|
||||
"dblclick" => r##"
|
||||
agent-browser dblclick - Double-click an element
|
||||
|
||||
Usage: agent-browser dblclick <selector>
|
||||
|
||||
Double-clicks on the specified element. Useful for text selection
|
||||
or triggering double-click handlers.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser dblclick "#editable-text"
|
||||
agent-browser dblclick @e5
|
||||
"##,
|
||||
"fill" => r##"
|
||||
agent-browser fill - Clear and fill an input field
|
||||
|
||||
Usage: agent-browser fill <selector> <text>
|
||||
|
||||
Clears the input field and fills it with the specified text.
|
||||
This replaces any existing content in the field.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser fill "#email" "user@example.com"
|
||||
agent-browser fill @e3 "Hello World"
|
||||
agent-browser fill "input[name='search']" "query"
|
||||
"##,
|
||||
"type" => r##"
|
||||
agent-browser type - Type text into an element
|
||||
|
||||
Usage: agent-browser type <selector> <text>
|
||||
|
||||
Types text into the specified element character by character.
|
||||
Unlike fill, this does not clear existing content first.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser type "#search" "hello"
|
||||
agent-browser type @e2 "additional text"
|
||||
"##,
|
||||
"hover" => r##"
|
||||
agent-browser hover - Hover over an element
|
||||
|
||||
Usage: agent-browser hover <selector>
|
||||
|
||||
Moves the mouse to hover over the specified element. Useful for
|
||||
triggering hover states or dropdown menus.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser hover "#dropdown-trigger"
|
||||
agent-browser hover @e4
|
||||
"##,
|
||||
"focus" => r##"
|
||||
agent-browser focus - Focus an element
|
||||
|
||||
Usage: agent-browser focus <selector>
|
||||
|
||||
Sets keyboard focus to the specified element.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser focus "#input-field"
|
||||
agent-browser focus @e2
|
||||
"##,
|
||||
"check" => r##"
|
||||
agent-browser check - Check a checkbox
|
||||
|
||||
Usage: agent-browser check <selector>
|
||||
|
||||
Checks a checkbox element. If already checked, no action is taken.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser check "#terms-checkbox"
|
||||
agent-browser check @e7
|
||||
"##,
|
||||
"uncheck" => r##"
|
||||
agent-browser uncheck - Uncheck a checkbox
|
||||
|
||||
Usage: agent-browser uncheck <selector>
|
||||
|
||||
Unchecks a checkbox element. If already unchecked, no action is taken.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser uncheck "#newsletter-opt-in"
|
||||
agent-browser uncheck @e8
|
||||
"##,
|
||||
"select" => r##"
|
||||
agent-browser select - Select a dropdown option
|
||||
|
||||
Usage: agent-browser select <selector> <value>
|
||||
|
||||
Selects an option in a <select> dropdown by its value attribute.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser select "#country" "US"
|
||||
agent-browser select @e5 "option2"
|
||||
"##,
|
||||
"drag" => r##"
|
||||
agent-browser drag - Drag and drop
|
||||
|
||||
Usage: agent-browser drag <source> <target>
|
||||
|
||||
Drags an element from source to target location.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser drag "#draggable" "#drop-zone"
|
||||
agent-browser drag @e1 @e2
|
||||
"##,
|
||||
"upload" => r##"
|
||||
agent-browser upload - Upload files
|
||||
|
||||
Usage: agent-browser upload <selector> <files...>
|
||||
|
||||
Uploads one or more files to a file input element.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser upload "#file-input" ./document.pdf
|
||||
agent-browser upload @e3 ./image1.png ./image2.png
|
||||
"##,
|
||||
|
||||
// === Keyboard ===
|
||||
"press" | "key" => r##"
|
||||
agent-browser press - Press a key or key combination
|
||||
|
||||
Usage: agent-browser press <key>
|
||||
|
||||
Presses a key or key combination. Supports special keys and modifiers.
|
||||
|
||||
Aliases: key
|
||||
|
||||
Special Keys:
|
||||
Enter, Tab, Escape, Backspace, Delete, Space
|
||||
ArrowUp, ArrowDown, ArrowLeft, ArrowRight
|
||||
Home, End, PageUp, PageDown
|
||||
F1-F12
|
||||
|
||||
Modifiers (combine with +):
|
||||
Control, Alt, Shift, Meta
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser press Enter
|
||||
agent-browser press Tab
|
||||
agent-browser press Control+a
|
||||
agent-browser press Control+Shift+s
|
||||
agent-browser press Escape
|
||||
"##,
|
||||
"keydown" => r##"
|
||||
agent-browser keydown - Press a key down (without release)
|
||||
|
||||
Usage: agent-browser keydown <key>
|
||||
|
||||
Presses a key down without releasing it. Use keyup to release.
|
||||
Useful for holding modifier keys.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser keydown Shift
|
||||
agent-browser keydown Control
|
||||
"##,
|
||||
"keyup" => r##"
|
||||
agent-browser keyup - Release a key
|
||||
|
||||
Usage: agent-browser keyup <key>
|
||||
|
||||
Releases a key that was pressed with keydown.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser keyup Shift
|
||||
agent-browser keyup Control
|
||||
"##,
|
||||
|
||||
// === Scroll ===
|
||||
"scroll" => r##"
|
||||
agent-browser scroll - Scroll the page
|
||||
|
||||
Usage: agent-browser scroll [direction] [amount]
|
||||
|
||||
Scrolls the page in the specified direction.
|
||||
|
||||
Arguments:
|
||||
direction up, down, left, right (default: down)
|
||||
amount Pixels to scroll (default: 300)
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser scroll
|
||||
agent-browser scroll down 500
|
||||
agent-browser scroll up 200
|
||||
agent-browser scroll left 100
|
||||
"##,
|
||||
"scrollintoview" | "scrollinto" => r##"
|
||||
agent-browser scrollintoview - Scroll element into view
|
||||
|
||||
Usage: agent-browser scrollintoview <selector>
|
||||
|
||||
Scrolls the page until the specified element is visible in the viewport.
|
||||
|
||||
Aliases: scrollinto
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser scrollintoview "#footer"
|
||||
agent-browser scrollintoview @e15
|
||||
"##,
|
||||
|
||||
// === Wait ===
|
||||
"wait" => r##"
|
||||
agent-browser wait - Wait for condition
|
||||
|
||||
Usage: agent-browser wait <selector|ms|option>
|
||||
|
||||
Waits for an element to appear, a timeout, or other conditions.
|
||||
|
||||
Modes:
|
||||
<selector> Wait for element to appear
|
||||
<ms> Wait for specified milliseconds
|
||||
--url <pattern> Wait for URL to match pattern
|
||||
--load <state> Wait for load state (load, domcontentloaded, networkidle)
|
||||
--fn <expression> Wait for JavaScript expression to be truthy
|
||||
--text <text> Wait for text to appear on page
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser wait "#loading-spinner"
|
||||
agent-browser wait 2000
|
||||
agent-browser wait --url "**/dashboard"
|
||||
agent-browser wait --load networkidle
|
||||
agent-browser wait --fn "window.appReady === true"
|
||||
agent-browser wait --text "Welcome back"
|
||||
"##,
|
||||
|
||||
// === Screenshot/PDF ===
|
||||
"screenshot" => r##"
|
||||
agent-browser screenshot - Take a screenshot
|
||||
|
||||
Usage: agent-browser screenshot [path]
|
||||
|
||||
Captures a screenshot of the current page. If no path is provided,
|
||||
outputs base64-encoded image data.
|
||||
|
||||
Options:
|
||||
--full, -f Capture full page (not just viewport)
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser screenshot
|
||||
agent-browser screenshot ./screenshot.png
|
||||
agent-browser screenshot --full ./full-page.png
|
||||
"##,
|
||||
"pdf" => r##"
|
||||
agent-browser pdf - Save page as PDF
|
||||
|
||||
Usage: agent-browser pdf <path>
|
||||
|
||||
Saves the current page as a PDF file.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser pdf ./page.pdf
|
||||
agent-browser pdf ~/Documents/report.pdf
|
||||
"##,
|
||||
|
||||
// === Snapshot ===
|
||||
"snapshot" => r##"
|
||||
agent-browser snapshot - Get accessibility tree snapshot
|
||||
|
||||
Usage: agent-browser snapshot [options]
|
||||
|
||||
Returns an accessibility tree representation of the page with element
|
||||
references (like @e1, @e2) that can be used in subsequent commands.
|
||||
Designed for AI agents to understand page structure.
|
||||
|
||||
Options:
|
||||
-i, --interactive Only include interactive elements
|
||||
-c, --compact Remove empty structural elements
|
||||
-d, --depth <n> Limit tree depth
|
||||
-s, --selector <sel> Scope snapshot to CSS selector
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser snapshot
|
||||
agent-browser snapshot -i
|
||||
agent-browser snapshot --compact --depth 5
|
||||
agent-browser snapshot -s "#main-content"
|
||||
"##,
|
||||
|
||||
// === Eval ===
|
||||
"eval" => r##"
|
||||
agent-browser eval - Execute JavaScript
|
||||
|
||||
Usage: agent-browser eval <script>
|
||||
|
||||
Executes JavaScript code in the browser context and returns the result.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser eval "document.title"
|
||||
agent-browser eval "window.location.href"
|
||||
agent-browser eval "document.querySelectorAll('a').length"
|
||||
"##,
|
||||
|
||||
// === Close ===
|
||||
"close" | "quit" | "exit" => r##"
|
||||
agent-browser close - Close the browser
|
||||
|
||||
Usage: agent-browser close
|
||||
|
||||
Closes the browser instance for the current session.
|
||||
|
||||
Aliases: quit, exit
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser close
|
||||
agent-browser close --session mysession
|
||||
"##,
|
||||
|
||||
// === Get ===
|
||||
"get" => r##"
|
||||
agent-browser get - Retrieve information from elements or page
|
||||
|
||||
Usage: agent-browser get <subcommand> [args]
|
||||
|
||||
Retrieves various types of information from elements or the page.
|
||||
|
||||
Subcommands:
|
||||
text <selector> Get text content of element
|
||||
html <selector> Get inner HTML of element
|
||||
value <selector> Get value of input element
|
||||
attr <selector> <name> Get attribute value
|
||||
title Get page title
|
||||
url Get current URL
|
||||
count <selector> Count matching elements
|
||||
box <selector> Get bounding box (x, y, width, height)
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser get text @e1
|
||||
agent-browser get html "#content"
|
||||
agent-browser get value "#email-input"
|
||||
agent-browser get attr "#link" href
|
||||
agent-browser get title
|
||||
agent-browser get url
|
||||
agent-browser get count "li.item"
|
||||
agent-browser get box "#header"
|
||||
"##,
|
||||
|
||||
// === Is ===
|
||||
"is" => r##"
|
||||
agent-browser is - Check element state
|
||||
|
||||
Usage: agent-browser is <subcommand> <selector>
|
||||
|
||||
Checks the state of an element and returns true/false.
|
||||
|
||||
Subcommands:
|
||||
visible <selector> Check if element is visible
|
||||
enabled <selector> Check if element is enabled (not disabled)
|
||||
checked <selector> Check if checkbox/radio is checked
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser is visible "#modal"
|
||||
agent-browser is enabled "#submit-btn"
|
||||
agent-browser is checked "#agree-checkbox"
|
||||
"##,
|
||||
|
||||
// === Find ===
|
||||
"find" => r##"
|
||||
agent-browser find - Find and interact with elements by locator
|
||||
|
||||
Usage: agent-browser find <locator> <value> [action] [text]
|
||||
|
||||
Finds elements using semantic locators and optionally performs an action.
|
||||
|
||||
Locators:
|
||||
role <role> Find by ARIA role (--name <n>, --exact)
|
||||
text <text> Find by text content (--exact)
|
||||
label <label> Find by associated label (--exact)
|
||||
placeholder <text> Find by placeholder text (--exact)
|
||||
alt <text> Find by alt text (--exact)
|
||||
title <text> Find by title attribute (--exact)
|
||||
testid <id> Find by data-testid attribute
|
||||
first <selector> First matching element
|
||||
last <selector> Last matching element
|
||||
nth <index> <selector> Nth matching element (0-based)
|
||||
|
||||
Actions (default: click):
|
||||
click, fill, type, hover, focus, check, uncheck
|
||||
|
||||
Options:
|
||||
--name <name> Filter role by accessible name
|
||||
--exact Require exact text match
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser find role button click --name Submit
|
||||
agent-browser find text "Sign In" click
|
||||
agent-browser find label "Email" fill "user@example.com"
|
||||
agent-browser find placeholder "Search..." type "query"
|
||||
agent-browser find testid "login-form" click
|
||||
agent-browser find first "li.item" click
|
||||
agent-browser find nth 2 ".card" hover
|
||||
"##,
|
||||
|
||||
// === Mouse ===
|
||||
"mouse" => r##"
|
||||
agent-browser mouse - Low-level mouse operations
|
||||
|
||||
Usage: agent-browser mouse <subcommand> [args]
|
||||
|
||||
Performs low-level mouse operations for precise control.
|
||||
|
||||
Subcommands:
|
||||
move <x> <y> Move mouse to coordinates
|
||||
down [button] Press mouse button (left, right, middle)
|
||||
up [button] Release mouse button
|
||||
wheel <dy> [dx] Scroll mouse wheel
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser mouse move 100 200
|
||||
agent-browser mouse down
|
||||
agent-browser mouse up
|
||||
agent-browser mouse down right
|
||||
agent-browser mouse wheel 100
|
||||
agent-browser mouse wheel -50 0
|
||||
"##,
|
||||
|
||||
// === Set ===
|
||||
"set" => r##"
|
||||
agent-browser set - Configure browser settings
|
||||
|
||||
Usage: agent-browser set <setting> [args]
|
||||
|
||||
Configures various browser settings and emulation options.
|
||||
|
||||
Settings:
|
||||
viewport <w> <h> Set viewport size
|
||||
device <name> Emulate device (e.g., "iPhone 12")
|
||||
geo <lat> <lng> Set geolocation
|
||||
offline [on|off] Toggle offline mode
|
||||
headers <json> Set extra HTTP headers
|
||||
credentials <user> <pass> Set HTTP authentication
|
||||
media [dark|light] Set color scheme preference
|
||||
[reduced-motion] Enable reduced motion
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser set viewport 1920 1080
|
||||
agent-browser set device "iPhone 12"
|
||||
agent-browser set geo 37.7749 -122.4194
|
||||
agent-browser set offline on
|
||||
agent-browser set headers '{"X-Custom": "value"}'
|
||||
agent-browser set credentials admin secret123
|
||||
agent-browser set media dark
|
||||
agent-browser set media light reduced-motion
|
||||
"##,
|
||||
|
||||
// === Network ===
|
||||
"network" => r##"
|
||||
agent-browser network - Network interception and monitoring
|
||||
|
||||
Usage: agent-browser network <subcommand> [args]
|
||||
|
||||
Intercept, mock, or monitor network requests.
|
||||
|
||||
Subcommands:
|
||||
route <url> [options] Intercept requests matching URL pattern
|
||||
--abort Abort matching requests
|
||||
--body <json> Respond with custom body
|
||||
unroute [url] Remove route (all if no URL)
|
||||
requests [options] List captured requests
|
||||
--clear Clear request log
|
||||
--filter <pattern> Filter by URL pattern
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser network route "**/api/*" --abort
|
||||
agent-browser network route "**/data.json" --body '{"mock": true}'
|
||||
agent-browser network unroute
|
||||
agent-browser network requests
|
||||
agent-browser network requests --filter "api"
|
||||
agent-browser network requests --clear
|
||||
"##,
|
||||
|
||||
// === Storage ===
|
||||
"storage" => r##"
|
||||
agent-browser storage - Manage web storage
|
||||
|
||||
Usage: agent-browser storage <type> [operation] [key] [value]
|
||||
|
||||
Manage localStorage and sessionStorage.
|
||||
|
||||
Types:
|
||||
local localStorage
|
||||
session sessionStorage
|
||||
|
||||
Operations:
|
||||
get [key] Get all storage or specific key
|
||||
set <key> <value> Set a key-value pair
|
||||
clear Clear all storage
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser storage local
|
||||
agent-browser storage local get authToken
|
||||
agent-browser storage local set theme "dark"
|
||||
agent-browser storage local clear
|
||||
agent-browser storage session get userId
|
||||
"##,
|
||||
|
||||
// === Cookies ===
|
||||
"cookies" => r##"
|
||||
agent-browser cookies - Manage browser cookies
|
||||
|
||||
Usage: agent-browser cookies [operation] [args]
|
||||
|
||||
Manage browser cookies for the current context.
|
||||
|
||||
Operations:
|
||||
get Get all cookies (default)
|
||||
set <name> <value> Set a cookie
|
||||
clear Clear all cookies
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser cookies
|
||||
agent-browser cookies get
|
||||
agent-browser cookies set session_id "abc123"
|
||||
agent-browser cookies clear
|
||||
"##,
|
||||
|
||||
// === Tabs ===
|
||||
"tab" => r##"
|
||||
agent-browser tab - Manage browser tabs
|
||||
|
||||
Usage: agent-browser tab [operation] [args]
|
||||
|
||||
Manage browser tabs in the current window.
|
||||
|
||||
Operations:
|
||||
list List all tabs (default)
|
||||
new [url] Open new tab
|
||||
close [index] Close tab (current if no index)
|
||||
<index> Switch to tab by index
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser tab
|
||||
agent-browser tab list
|
||||
agent-browser tab new
|
||||
agent-browser tab new https://example.com
|
||||
agent-browser tab 2
|
||||
agent-browser tab close
|
||||
agent-browser tab close 1
|
||||
"##,
|
||||
|
||||
// === Window ===
|
||||
"window" => r##"
|
||||
agent-browser window - Manage browser windows
|
||||
|
||||
Usage: agent-browser window <operation>
|
||||
|
||||
Manage browser windows.
|
||||
|
||||
Operations:
|
||||
new Open new browser window
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser window new
|
||||
"##,
|
||||
|
||||
// === Frame ===
|
||||
"frame" => r##"
|
||||
agent-browser frame - Switch frame context
|
||||
|
||||
Usage: agent-browser frame <selector|main>
|
||||
|
||||
Switch to an iframe or back to the main frame.
|
||||
|
||||
Arguments:
|
||||
<selector> CSS selector for iframe
|
||||
main Switch back to main frame
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser frame "#embed-iframe"
|
||||
agent-browser frame "iframe[name='content']"
|
||||
agent-browser frame main
|
||||
"##,
|
||||
|
||||
// === Dialog ===
|
||||
"dialog" => r##"
|
||||
agent-browser dialog - Handle browser dialogs
|
||||
|
||||
Usage: agent-browser dialog <response> [text]
|
||||
|
||||
Respond to browser dialogs (alert, confirm, prompt).
|
||||
|
||||
Operations:
|
||||
accept [text] Accept dialog, optionally with prompt text
|
||||
dismiss Dismiss/cancel dialog
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser dialog accept
|
||||
agent-browser dialog accept "my input"
|
||||
agent-browser dialog dismiss
|
||||
"##,
|
||||
|
||||
// === Trace ===
|
||||
"trace" => r##"
|
||||
agent-browser trace - Record execution trace
|
||||
|
||||
Usage: agent-browser trace <operation> [path]
|
||||
|
||||
Record a trace for debugging with Playwright Trace Viewer.
|
||||
|
||||
Operations:
|
||||
start [path] Start recording trace
|
||||
stop [path] Stop recording and save trace
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser trace start
|
||||
agent-browser trace start ./my-trace
|
||||
agent-browser trace stop
|
||||
agent-browser trace stop ./debug-trace.zip
|
||||
"##,
|
||||
|
||||
// === Console/Errors ===
|
||||
"console" => r##"
|
||||
agent-browser console - View console logs
|
||||
|
||||
Usage: agent-browser console [--clear]
|
||||
|
||||
View browser console output (log, warn, error, info).
|
||||
|
||||
Options:
|
||||
--clear Clear console log buffer
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser console
|
||||
agent-browser console --clear
|
||||
"##,
|
||||
"errors" => r##"
|
||||
agent-browser errors - View page errors
|
||||
|
||||
Usage: agent-browser errors [--clear]
|
||||
|
||||
View JavaScript errors and uncaught exceptions.
|
||||
|
||||
Options:
|
||||
--clear Clear error buffer
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser errors
|
||||
agent-browser errors --clear
|
||||
"##,
|
||||
|
||||
// === Highlight ===
|
||||
"highlight" => r##"
|
||||
agent-browser highlight - Highlight an element
|
||||
|
||||
Usage: agent-browser highlight <selector>
|
||||
|
||||
Visually highlights an element on the page for debugging.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser highlight "#target-element"
|
||||
agent-browser highlight @e5
|
||||
"##,
|
||||
|
||||
// === State ===
|
||||
"state" => r##"
|
||||
agent-browser state - Save/load browser state
|
||||
|
||||
Usage: agent-browser state <operation> <path>
|
||||
|
||||
Save or restore browser state (cookies, localStorage, sessionStorage).
|
||||
|
||||
Operations:
|
||||
save <path> Save current state to file
|
||||
load <path> Load state from file
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser state save ./auth-state.json
|
||||
agent-browser state load ./auth-state.json
|
||||
"##,
|
||||
|
||||
// === Session ===
|
||||
"session" => r##"
|
||||
agent-browser session - Manage sessions
|
||||
|
||||
Usage: agent-browser session [operation]
|
||||
|
||||
Manage isolated browser sessions. Each session has its own browser
|
||||
instance with separate cookies, storage, and state.
|
||||
|
||||
Operations:
|
||||
(none) Show current session name
|
||||
list List all active sessions
|
||||
|
||||
Environment:
|
||||
AGENT_BROWSER_SESSION Default session name
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser session
|
||||
agent-browser session list
|
||||
agent-browser --session test open example.com
|
||||
"##,
|
||||
|
||||
// === Install ===
|
||||
"install" => r##"
|
||||
agent-browser install - Install browser binaries
|
||||
|
||||
Usage: agent-browser install [--with-deps]
|
||||
|
||||
Downloads and installs browser binaries required for automation.
|
||||
|
||||
Options:
|
||||
-d, --with-deps Also install system dependencies (Linux only)
|
||||
|
||||
Examples:
|
||||
agent-browser install
|
||||
agent-browser install --with-deps
|
||||
"##,
|
||||
|
||||
_ => return false,
|
||||
};
|
||||
println!("{}", help.trim());
|
||||
true
|
||||
}
|
||||
|
||||
pub fn print_help() {
|
||||
println!(
|
||||
r#"
|
||||
@@ -231,6 +1189,8 @@ Snapshot Options:
|
||||
|
||||
Options:
|
||||
--session <name> Isolated session (or AGENT_BROWSER_SESSION env)
|
||||
--headers <json> HTTP headers scoped to URL's origin (for auth)
|
||||
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
|
||||
--json JSON output
|
||||
--full, -f Full page screenshot
|
||||
--headed Show browser window (not headless)
|
||||
|
||||
+9
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agent-browser",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.3",
|
||||
"description": "Headless browser automation CLI for AI agents",
|
||||
"type": "module",
|
||||
"main": "dist/daemon.js",
|
||||
@@ -15,13 +15,16 @@
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
"version:sync": "node scripts/sync-version.js",
|
||||
"version": "npm run version:sync && git add cli/Cargo.toml",
|
||||
"build": "tsc",
|
||||
"build:native": "cargo build --release --manifest-path cli/Cargo.toml && node scripts/copy-native.js",
|
||||
"build:linux": "docker compose -f docker/docker-compose.yml run --rm build-linux",
|
||||
"build:macos": "(cargo build --release --manifest-path cli/Cargo.toml --target aarch64-apple-darwin & cargo build --release --manifest-path cli/Cargo.toml --target x86_64-apple-darwin & wait) && cp cli/target/aarch64-apple-darwin/release/agent-browser bin/agent-browser-darwin-arm64 && cp cli/target/x86_64-apple-darwin/release/agent-browser bin/agent-browser-darwin-x64",
|
||||
"build:windows": "docker compose -f docker/docker-compose.yml run --rm build-windows",
|
||||
"build:all-platforms": "(npm run build:linux & npm run build:windows & wait) && npm run build:macos",
|
||||
"build:native": "npm run version:sync && cargo build --release --manifest-path cli/Cargo.toml && node scripts/copy-native.js",
|
||||
"build:linux": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-linux",
|
||||
"build:macos": "npm run version:sync && (cargo build --release --manifest-path cli/Cargo.toml --target aarch64-apple-darwin & cargo build --release --manifest-path cli/Cargo.toml --target x86_64-apple-darwin & wait) && cp cli/target/aarch64-apple-darwin/release/agent-browser bin/agent-browser-darwin-arm64 && cp cli/target/x86_64-apple-darwin/release/agent-browser bin/agent-browser-darwin-x64",
|
||||
"build:windows": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-windows",
|
||||
"build:all-platforms": "npm run version:sync && (npm run build:linux & npm run build:windows & wait) && npm run build:macos",
|
||||
"build:docker": "docker build -t agent-browser-builder -f docker/Dockerfile.build .",
|
||||
"release": "npm run version:sync && npm run build && npm run build:all-platforms && npm publish",
|
||||
"start": "node dist/daemon.js",
|
||||
"dev": "tsx src/daemon.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Syncs the version from package.json to all other config files.
|
||||
* Run this script before building or releasing.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, "..");
|
||||
|
||||
// Read version from package.json (single source of truth)
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(join(rootDir, "package.json"), "utf-8")
|
||||
);
|
||||
const version = packageJson.version;
|
||||
|
||||
console.log(`Syncing version ${version} to all config files...`);
|
||||
|
||||
// Update Cargo.toml
|
||||
const cargoTomlPath = join(rootDir, "cli/Cargo.toml");
|
||||
let cargoToml = readFileSync(cargoTomlPath, "utf-8");
|
||||
const cargoVersionRegex = /^version\s*=\s*"[^"]*"/m;
|
||||
const newCargoVersion = `version = "${version}"`;
|
||||
|
||||
if (cargoVersionRegex.test(cargoToml)) {
|
||||
const oldMatch = cargoToml.match(cargoVersionRegex)?.[0];
|
||||
if (oldMatch !== newCargoVersion) {
|
||||
cargoToml = cargoToml.replace(cargoVersionRegex, newCargoVersion);
|
||||
writeFileSync(cargoTomlPath, cargoToml);
|
||||
console.log(` Updated cli/Cargo.toml: ${oldMatch} -> ${newCargoVersion}`);
|
||||
} else {
|
||||
console.log(` cli/Cargo.toml already up to date`);
|
||||
}
|
||||
} else {
|
||||
console.error(" Could not find version field in cli/Cargo.toml");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Version sync complete.");
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: browsing-web
|
||||
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.
|
||||
---
|
||||
|
||||
@@ -411,6 +411,12 @@ 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',
|
||||
});
|
||||
|
||||
@@ -22,6 +22,16 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigation', () => {
|
||||
@@ -294,4 +304,59 @@ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+75
-2
@@ -51,6 +51,7 @@ export class BrowserManager {
|
||||
private isRecordingHar: boolean = false;
|
||||
private refMap: RefMap = {};
|
||||
private lastSnapshot: string = '';
|
||||
private scopedHeaderRoutes: Map<string, (route: Route) => Promise<void>> = new Map();
|
||||
|
||||
/**
|
||||
* Check if browser is launched
|
||||
@@ -439,7 +440,7 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set extra HTTP headers
|
||||
* Set extra HTTP headers (global - all requests)
|
||||
*/
|
||||
async setExtraHeaders(headers: Record<string, string>): Promise<void> {
|
||||
const context = this.contexts[0];
|
||||
@@ -448,6 +449,76 @@ 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
|
||||
*/
|
||||
@@ -520,11 +591,13 @@ export class BrowserManager {
|
||||
// Launch browser
|
||||
this.browser = await launcher.launch({
|
||||
headless: options.headless ?? true,
|
||||
executablePath: options.executablePath,
|
||||
});
|
||||
|
||||
// Create context with viewport
|
||||
// Create context with viewport and optional headers
|
||||
const context = await this.browser.newContext({
|
||||
viewport: options.viewport ?? { width: 1280, height: 720 },
|
||||
extraHTTPHeaders: options.headers,
|
||||
});
|
||||
|
||||
// Set default timeout to 10 seconds (Playwright default is 30s)
|
||||
|
||||
+6
-1
@@ -158,7 +158,12 @@ export async function startDaemon(): Promise<void> {
|
||||
parseResult.command.action !== 'launch' &&
|
||||
parseResult.command.action !== 'close'
|
||||
) {
|
||||
await browser.launch({ id: 'auto', action: 'launch', headless: true });
|
||||
await browser.launch({
|
||||
id: 'auto',
|
||||
action: 'launch',
|
||||
headless: true,
|
||||
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle close command specially
|
||||
|
||||
@@ -12,12 +12,15 @@ export interface LaunchCommand extends BaseCommand {
|
||||
headless?: boolean;
|
||||
viewport?: { width: number; height: number };
|
||||
browser?: 'chromium' | 'firefox' | 'webkit';
|
||||
headers?: Record<string, string>;
|
||||
executablePath?: string;
|
||||
}
|
||||
|
||||
export interface NavigateCommand extends BaseCommand {
|
||||
action: 'navigate';
|
||||
url: string;
|
||||
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ClickCommand extends BaseCommand {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 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
@@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config';
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
include: ['src/**/*.test.ts'],
|
||||
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user