Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3a302056b | ||
|
|
0eb5936f4b | ||
|
|
a533ee8aea | ||
|
|
c6d5f9bca1 | ||
|
|
400dc8b850 | ||
|
|
95675e9d55 | ||
|
|
97b17c98fb | ||
|
|
57a04385c1 | ||
|
|
1a88d7f585 | ||
|
|
4f6fd8ec5c | ||
|
|
3cd0ab468f | ||
|
|
a4fcc1c198 | ||
|
|
574037080c | ||
|
|
278466764b |
@@ -83,3 +83,106 @@ jobs:
|
|||||||
|
|
||||||
- name: Build release binary
|
- name: Build release binary
|
||||||
run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
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
|
||||||
|
|||||||
@@ -42,3 +42,9 @@ yarn.lock
|
|||||||
|
|
||||||
# opensrc - source code for packages
|
# opensrc - source code for packages
|
||||||
opensrc/
|
opensrc/
|
||||||
|
|
||||||
|
# Docs site
|
||||||
|
docs/node_modules/
|
||||||
|
docs/.next/
|
||||||
|
docs/out/
|
||||||
|
docs/package-lock.json
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ git clone https://github.com/vercel-labs/agent-browser
|
|||||||
cd agent-browser
|
cd agent-browser
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm build
|
pnpm build
|
||||||
agent-browser install
|
pnpm build:native
|
||||||
|
./bin/agent-browser install
|
||||||
|
pnpm link --global
|
||||||
```
|
```
|
||||||
|
|
||||||
### Linux Dependencies
|
### Linux Dependencies
|
||||||
@@ -55,13 +57,13 @@ agent-browser find role button click --name "Submit"
|
|||||||
### Core Commands
|
### Core Commands
|
||||||
|
|
||||||
```bash
|
```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 click <sel> # Click element
|
||||||
agent-browser dblclick <sel> # Double-click element
|
agent-browser dblclick <sel> # Double-click element
|
||||||
agent-browser focus <sel> # Focus element
|
agent-browser focus <sel> # Focus element
|
||||||
agent-browser type <sel> <text> # Type into element
|
agent-browser type <sel> <text> # Type into element
|
||||||
agent-browser fill <sel> <text> # Clear and fill
|
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 keydown <key> # Hold key down
|
||||||
agent-browser keyup <key> # Release key
|
agent-browser keyup <key> # Release key
|
||||||
agent-browser hover <sel> # Hover element
|
agent-browser hover <sel> # Hover element
|
||||||
@@ -69,14 +71,14 @@ agent-browser select <sel> <val> # Select dropdown option
|
|||||||
agent-browser check <sel> # Check checkbox
|
agent-browser check <sel> # Check checkbox
|
||||||
agent-browser uncheck <sel> # Uncheck checkbox
|
agent-browser uncheck <sel> # Uncheck checkbox
|
||||||
agent-browser scroll <dir> [px] # Scroll (up/down/left/right)
|
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 drag <src> <tgt> # Drag and drop
|
||||||
agent-browser upload <sel> <files> # Upload files
|
agent-browser upload <sel> <files> # Upload files
|
||||||
agent-browser screenshot [path] # Take screenshot (--full for full page)
|
agent-browser screenshot [path] # Take screenshot (--full for full page)
|
||||||
agent-browser pdf <path> # Save as PDF
|
agent-browser pdf <path> # Save as PDF
|
||||||
agent-browser snapshot # Accessibility tree with refs (best for AI)
|
agent-browser snapshot # Accessibility tree with refs (best for AI)
|
||||||
agent-browser eval <js> # Run JavaScript
|
agent-browser eval <js> # Run JavaScript
|
||||||
agent-browser close # Close browser
|
agent-browser close # Close browser (aliases: quit, exit)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Get Info
|
### Get Info
|
||||||
@@ -129,9 +131,9 @@ agent-browser find nth 2 "a" text
|
|||||||
### Wait
|
### Wait
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
agent-browser wait <selector> # Wait for element
|
agent-browser wait <selector> # Wait for element to be visible
|
||||||
agent-browser wait <ms> # Wait for time
|
agent-browser wait <ms> # Wait for time (milliseconds)
|
||||||
agent-browser wait --text "Welcome" # Wait for text
|
agent-browser wait --text "Welcome" # Wait for text to appear
|
||||||
agent-browser wait --url "**/dash" # Wait for URL pattern
|
agent-browser wait --url "**/dash" # Wait for URL pattern
|
||||||
agent-browser wait --load networkidle # Wait for load state
|
agent-browser wait --load networkidle # Wait for load state
|
||||||
agent-browser wait --fn "window.ready === true" # Wait for JS condition
|
agent-browser wait --fn "window.ready === true" # Wait for JS condition
|
||||||
@@ -253,6 +255,10 @@ AGENT_BROWSER_SESSION=agent1 agent-browser click "#btn"
|
|||||||
|
|
||||||
# List active sessions
|
# List active sessions
|
||||||
agent-browser session list
|
agent-browser session list
|
||||||
|
# Output:
|
||||||
|
# Active sessions:
|
||||||
|
# -> default
|
||||||
|
# agent1
|
||||||
|
|
||||||
# Show current session
|
# Show current session
|
||||||
agent-browser session
|
agent-browser session
|
||||||
@@ -289,11 +295,14 @@ agent-browser snapshot -i -c -d 5 # Combine options
|
|||||||
| Option | Description |
|
| Option | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `--session <name>` | Use isolated session (or `AGENT_BROWSER_SESSION` env) |
|
| `--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) |
|
| `--json` | JSON output (for agents) |
|
||||||
| `--full, -f` | Full page screenshot |
|
| `--full, -f` | Full page screenshot |
|
||||||
| `--name, -n` | Locator name filter |
|
| `--name, -n` | Locator name filter |
|
||||||
| `--exact` | Exact text match |
|
| `--exact` | Exact text match |
|
||||||
| `--headed` | Show browser window (not headless) |
|
| `--headed` | Show browser window (not headless) |
|
||||||
|
| `--cdp <port>` | Connect via Chrome DevTools Protocol |
|
||||||
| `--debug` | Debug output |
|
| `--debug` | Debug output |
|
||||||
|
|
||||||
## Selectors
|
## Selectors
|
||||||
@@ -383,6 +392,93 @@ agent-browser open example.com --headed
|
|||||||
|
|
||||||
This opens a visible browser window instead of running headless.
|
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
|
||||||
|
# Connect to Electron app
|
||||||
|
agent-browser --cdp 9222 snapshot
|
||||||
|
|
||||||
|
# Connect to Chrome with remote debugging
|
||||||
|
# (Start Chrome with: google-chrome --remote-debugging-port=9222)
|
||||||
|
agent-browser --cdp 9222 open about:blank
|
||||||
|
```
|
||||||
|
|
||||||
|
This enables control of:
|
||||||
|
- Electron apps
|
||||||
|
- Chrome/Chromium instances with remote debugging
|
||||||
|
- WebView2 applications
|
||||||
|
- Any browser exposing a CDP endpoint
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
agent-browser uses a client-daemon architecture:
|
agent-browser uses a client-daemon architecture:
|
||||||
@@ -393,15 +489,17 @@ agent-browser uses a client-daemon architecture:
|
|||||||
|
|
||||||
The daemon starts automatically on first command and persists between commands for fast subsequent operations.
|
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
|
## Platforms
|
||||||
|
|
||||||
| Platform | Binary | Fallback |
|
| Platform | Binary | Fallback |
|
||||||
|----------|--------|----------|
|
|----------|--------|----------|
|
||||||
| macOS ARM64 | ✅ Native Rust | Node.js |
|
| macOS ARM64 | Native Rust | Node.js |
|
||||||
| macOS x64 | ✅ Native Rust | Node.js |
|
| macOS x64 | Native Rust | Node.js |
|
||||||
| Linux ARM64 | ✅ Native Rust | Node.js |
|
| Linux ARM64 | Native Rust | Node.js |
|
||||||
| Linux x64 | ✅ Native Rust | Node.js |
|
| Linux x64 | Native Rust | Node.js |
|
||||||
| Windows | - | Node.js |
|
| Windows x64 | Native Rust | Node.js |
|
||||||
|
|
||||||
## Usage with AI Agents
|
## Usage with AI Agents
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-browser"
|
name = "agent-browser"
|
||||||
version = "0.4.2"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agent-browser"
|
name = "agent-browser"
|
||||||
version = "0.4.2"
|
version = "0.4.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Fast browser automation CLI for AI agents"
|
description = "Fast browser automation CLI for AI agents"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
+182
-3
@@ -80,7 +80,14 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
|||||||
} else {
|
} else {
|
||||||
format!("https://{}", url)
|
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" })),
|
"back" => Ok(json!({ "id": id, "action": "back" })),
|
||||||
"forward" => Ok(json!({ "id": id, "action": "forward" })),
|
"forward" => Ok(json!({ "id": id, "action": "forward" })),
|
||||||
@@ -212,6 +219,44 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
|||||||
|
|
||||||
// === Wait ===
|
// === Wait ===
|
||||||
"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 let Some(arg) = rest.get(0) {
|
||||||
if arg.parse::<u64>().is_ok() {
|
if arg.parse::<u64>().is_ok() {
|
||||||
Ok(json!({ "id": id, "action": "wait", "timeout": arg.parse::<u64>().unwrap() }))
|
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 {
|
} else {
|
||||||
Err(ParseError::MissingArguments {
|
Err(ParseError::MissingArguments {
|
||||||
context: "wait".to_string(),
|
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(),
|
context: "set headers".to_string(),
|
||||||
usage: "set headers <json>",
|
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") => {
|
Some("credentials") | Some("auth") => {
|
||||||
let user = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
let user = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
||||||
@@ -848,6 +899,8 @@ mod tests {
|
|||||||
full: false,
|
full: false,
|
||||||
headed: false,
|
headed: false,
|
||||||
debug: false,
|
debug: false,
|
||||||
|
headers: None,
|
||||||
|
executable_path: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -974,6 +1027,81 @@ mod tests {
|
|||||||
assert_eq!(cmd["url"], "https://example.com");
|
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]
|
#[test]
|
||||||
fn test_back() {
|
fn test_back() {
|
||||||
let cmd = parse_command(&args("back"), &default_flags()).unwrap();
|
let cmd = parse_command(&args("back"), &default_flags()).unwrap();
|
||||||
@@ -1090,6 +1218,57 @@ mod tests {
|
|||||||
assert_eq!(cmd["maxDepth"], 3);
|
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 ===
|
// === Unknown command ===
|
||||||
|
|
||||||
#[test]
|
#[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) {
|
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())?;
|
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");
|
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
|
// Create new process group and session to fully detach
|
||||||
unsafe {
|
unsafe {
|
||||||
cmd.pre_exec(|| {
|
cmd.pre_exec(|| {
|
||||||
@@ -206,8 +216,13 @@ pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> {
|
|||||||
{
|
{
|
||||||
use std::os::windows::process::CommandExt;
|
use std::os::windows::process::CommandExt;
|
||||||
|
|
||||||
let mut cmd = Command::new("node");
|
// On Windows, use cmd.exe to run node to ensure proper PATH resolution.
|
||||||
cmd.arg(daemon_path)
|
// 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_DAEMON", "1")
|
||||||
.env("AGENT_BROWSER_SESSION", session);
|
.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");
|
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
|
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
|
||||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||||
@@ -229,7 +248,7 @@ pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> {
|
|||||||
|
|
||||||
for _ in 0..50 {
|
for _ in 0..50 {
|
||||||
if daemon_ready(session) {
|
if daemon_ready(session) {
|
||||||
return Ok(());
|
return Ok(DaemonResult { already_running: false });
|
||||||
}
|
}
|
||||||
thread::sleep(Duration::from_millis(100));
|
thread::sleep(Duration::from_millis(100));
|
||||||
}
|
}
|
||||||
|
|||||||
+135
-1
@@ -6,6 +6,9 @@ pub struct Flags {
|
|||||||
pub headed: bool,
|
pub headed: bool,
|
||||||
pub debug: bool,
|
pub debug: bool,
|
||||||
pub session: String,
|
pub session: String,
|
||||||
|
pub headers: Option<String>,
|
||||||
|
pub executable_path: Option<String>,
|
||||||
|
pub cdp: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_flags(args: &[String]) -> Flags {
|
pub fn parse_flags(args: &[String]) -> Flags {
|
||||||
@@ -15,6 +18,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
headed: false,
|
headed: false,
|
||||||
debug: false,
|
debug: false,
|
||||||
session: env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()),
|
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
@@ -30,6 +36,24 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
i += 1;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--cdp" => {
|
||||||
|
if let Some(s) = args.get(i + 1) {
|
||||||
|
flags.cdp = Some(s.clone());
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
i += 1;
|
i += 1;
|
||||||
@@ -43,13 +67,15 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
|||||||
|
|
||||||
// Global flags that should be stripped from command args
|
// Global flags that should be stripped from command args
|
||||||
const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"];
|
const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"];
|
||||||
|
// Global flags that take a value (need to skip the next arg too)
|
||||||
|
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers", "--executable-path", "--cdp"];
|
||||||
|
|
||||||
for arg in args.iter() {
|
for arg in args.iter() {
|
||||||
if skip_next {
|
if skip_next {
|
||||||
skip_next = false;
|
skip_next = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if arg == "--session" {
|
if GLOBAL_FLAGS_WITH_VALUE.contains(&arg.as_str()) {
|
||||||
skip_next = true;
|
skip_next = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -61,3 +87,111 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
result
|
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");
|
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")
|
let status = Command::new("npx")
|
||||||
.args(["playwright", "install", "chromium"])
|
.args(["playwright", "install", "chromium"])
|
||||||
.status();
|
.status();
|
||||||
|
|||||||
+89
-7
@@ -21,7 +21,7 @@ use commands::{gen_id, parse_command, ParseError};
|
|||||||
use connection::{ensure_daemon, send_command};
|
use connection::{ensure_daemon, send_command};
|
||||||
use flags::{clean_args, parse_flags};
|
use flags::{clean_args, parse_flags};
|
||||||
use install::run_install;
|
use install::run_install;
|
||||||
use output::{print_help, print_response};
|
use output::{print_command_help, print_help, print_response};
|
||||||
|
|
||||||
fn run_session(args: &[String], session: &str, json_mode: bool) {
|
fn run_session(args: &[String], session: &str, json_mode: bool) {
|
||||||
let subcommand = args.get(1).map(|s| s.as_str());
|
let subcommand = args.get(1).map(|s| s.as_str());
|
||||||
@@ -98,7 +98,19 @@ fn main() {
|
|||||||
let flags = parse_flags(&args);
|
let flags = parse_flags(&args);
|
||||||
let clean = clean_args(&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();
|
print_help();
|
||||||
return;
|
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 {
|
if flags.json {
|
||||||
println!(r#"{{"success":false,"error":"{}"}}"#, e);
|
println!(r#"{{"success":false,"error":"{}"}}"#, e);
|
||||||
} else {
|
} else {
|
||||||
@@ -145,13 +159,81 @@ fn main() {
|
|||||||
}
|
}
|
||||||
exit(1);
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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!("\x1b[31m✗\x1b[0m {}", 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!("\x1b[31m✗\x1b[0m {}", 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!("\x1b[31m✗\x1b[0m {}", 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!("\x1b[31m✗\x1b[0m {}", msg);
|
||||||
|
}
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch headed browser if --headed flag is set (without CDP)
|
||||||
|
if flags.headed && flags.cdp.is_none() {
|
||||||
|
let launch_cmd = json!({
|
||||||
|
"id": gen_id(),
|
||||||
|
"action": "launch",
|
||||||
|
"headless": false
|
||||||
|
});
|
||||||
|
|
||||||
// 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 let Err(e) = send_command(launch_cmd, &flags.session) {
|
||||||
if !flags.json {
|
if !flags.json {
|
||||||
eprintln!("\x1b[33m⚠\x1b[0m Could not switch to headed mode: {}", e);
|
eprintln!("\x1b[33m⚠\x1b[0m Could not launch headed browser: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
pub fn print_help() {
|
||||||
println!(
|
println!(
|
||||||
r#"
|
r#"
|
||||||
@@ -231,9 +1189,12 @@ Snapshot Options:
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--session <name> Isolated session (or AGENT_BROWSER_SESSION env)
|
--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
|
--json JSON output
|
||||||
--full, -f Full page screenshot
|
--full, -f Full page screenshot
|
||||||
--headed Show browser window (not headless)
|
--headed Show browser window (not headless)
|
||||||
|
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
|
||||||
--debug Debug output
|
--debug Debug output
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -244,6 +1205,7 @@ Examples:
|
|||||||
agent-browser find role button click --name Submit
|
agent-browser find role button click --name Submit
|
||||||
agent-browser get text @e1
|
agent-browser get text @e1
|
||||||
agent-browser screenshot --full
|
agent-browser screenshot --full
|
||||||
|
agent-browser --cdp 9222 snapshot # Connect via CDP port
|
||||||
"#
|
"#
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4327
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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={`# Connect to Electron app
|
||||||
|
agent-browser --cdp 9222 snapshot
|
||||||
|
|
||||||
|
# Connect to Chrome with remote debugging
|
||||||
|
# (Start Chrome with: google-chrome --remote-debugging-port=9222)
|
||||||
|
agent-browser --cdp 9222 open about:blank`} />
|
||||||
|
|
||||||
|
<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 <name></code></td>
|
||||||
|
<td>Use isolated session</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>--headers <json></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 <port></code></td>
|
||||||
|
<td>CDP connection port</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>--debug</code></td>
|
||||||
|
<td>Debug output</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
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.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,182 @@
|
|||||||
|
@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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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 page.png
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"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: "Agent Mode", href: "/agent-mode" },
|
||||||
|
{ name: "CDP Mode", href: "/cdp-mode" },
|
||||||
|
];
|
||||||
|
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"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
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agent-browser",
|
"name": "agent-browser",
|
||||||
"version": "0.4.2",
|
"version": "0.4.4",
|
||||||
"description": "Headless browser automation CLI for AI agents",
|
"description": "Headless browser automation CLI for AI agents",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/daemon.js",
|
"main": "dist/daemon.js",
|
||||||
|
|||||||
@@ -411,6 +411,12 @@ async function handleNavigate(
|
|||||||
browser: BrowserManager
|
browser: BrowserManager
|
||||||
): Promise<Response<NavigateData>> {
|
): Promise<Response<NavigateData>> {
|
||||||
const page = browser.getPage();
|
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, {
|
await page.goto(command.url, {
|
||||||
waitUntil: command.waitUntil ?? 'load',
|
waitUntil: command.waitUntil ?? 'load',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,35 @@ describe('BrowserManager', () => {
|
|||||||
const page = browser.getPage();
|
const page = browser.getPage();
|
||||||
expect(page).toBeDefined();
|
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', () => {
|
describe('navigation', () => {
|
||||||
@@ -294,4 +323,59 @@ describe('BrowserManager', () => {
|
|||||||
expect(h1).toBe('Example Domain');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+204
-14
@@ -39,6 +39,7 @@ interface PageError {
|
|||||||
*/
|
*/
|
||||||
export class BrowserManager {
|
export class BrowserManager {
|
||||||
private browser: Browser | null = null;
|
private browser: Browser | null = null;
|
||||||
|
private cdpPort: number | null = null;
|
||||||
private contexts: BrowserContext[] = [];
|
private contexts: BrowserContext[] = [];
|
||||||
private pages: Page[] = [];
|
private pages: Page[] = [];
|
||||||
private activePageIndex: number = 0;
|
private activePageIndex: number = 0;
|
||||||
@@ -51,6 +52,7 @@ export class BrowserManager {
|
|||||||
private isRecordingHar: boolean = false;
|
private isRecordingHar: boolean = false;
|
||||||
private refMap: RefMap = {};
|
private refMap: RefMap = {};
|
||||||
private lastSnapshot: string = '';
|
private lastSnapshot: string = '';
|
||||||
|
private scopedHeaderRoutes: Map<string, (route: Route) => Promise<void>> = new Map();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if browser is launched
|
* Check if browser is launched
|
||||||
@@ -439,7 +441,7 @@ export class BrowserManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set extra HTTP headers
|
* Set extra HTTP headers (global - all requests)
|
||||||
*/
|
*/
|
||||||
async setExtraHeaders(headers: Record<string, string>): Promise<void> {
|
async setExtraHeaders(headers: Record<string, string>): Promise<void> {
|
||||||
const context = this.contexts[0];
|
const context = this.contexts[0];
|
||||||
@@ -448,6 +450,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
|
* Start tracing
|
||||||
*/
|
*/
|
||||||
@@ -502,13 +574,51 @@ export class BrowserManager {
|
|||||||
return this.browser;
|
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
|
* Launch the browser with the specified options
|
||||||
* If already launched, this is a no-op (browser stays open)
|
* If already launched, this is a no-op (browser stays open)
|
||||||
*/
|
*/
|
||||||
async launch(options: LaunchCommand): Promise<void> {
|
async launch(options: LaunchCommand): Promise<void> {
|
||||||
// If already launched, don't relaunch
|
const cdpPort = options.cdpPort;
|
||||||
|
|
||||||
if (this.browser) {
|
if (this.browser) {
|
||||||
|
const switchingFromCdpToBrowser = !cdpPort && this.cdpPort !== null;
|
||||||
|
const needsCdpReconnect = !!cdpPort && this.needsCdpReconnect(cdpPort);
|
||||||
|
|
||||||
|
if (switchingFromCdpToBrowser || needsCdpReconnect) {
|
||||||
|
await this.close();
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cdpPort) {
|
||||||
|
await this.connectViaCDP(cdpPort);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,11 +630,14 @@ export class BrowserManager {
|
|||||||
// Launch browser
|
// Launch browser
|
||||||
this.browser = await launcher.launch({
|
this.browser = await launcher.launch({
|
||||||
headless: options.headless ?? true,
|
headless: options.headless ?? true,
|
||||||
|
executablePath: options.executablePath,
|
||||||
});
|
});
|
||||||
|
this.cdpPort = null;
|
||||||
|
|
||||||
// Create context with viewport
|
// Create context with viewport and optional headers
|
||||||
const context = await this.browser.newContext({
|
const context = await this.browser.newContext({
|
||||||
viewport: options.viewport ?? { width: 1280, height: 720 },
|
viewport: options.viewport ?? { width: 1280, height: 720 },
|
||||||
|
extraHTTPHeaders: options.headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set default timeout to 10 seconds (Playwright default is 30s)
|
// Set default timeout to 10 seconds (Playwright default is 30s)
|
||||||
@@ -542,7 +655,56 @@ export class BrowserManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up console and error tracking for a 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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPages = contexts.flatMap((context) => context.pages());
|
||||||
|
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
|
||||||
*/
|
*/
|
||||||
private setupPageTracking(page: Page): void {
|
private setupPageTracking(page: Page): void {
|
||||||
page.on('console', (msg) => {
|
page.on('console', (msg) => {
|
||||||
@@ -559,6 +721,26 @@ export class BrowserManager {
|
|||||||
timestamp: Date.now(),
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -672,21 +854,29 @@ export class BrowserManager {
|
|||||||
* Close the browser and clean up
|
* Close the browser and clean up
|
||||||
*/
|
*/
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
for (const page of this.pages) {
|
// CDP: only disconnect, don't close external app's pages
|
||||||
await page.close().catch(() => {});
|
if (this.cdpPort !== null) {
|
||||||
}
|
|
||||||
this.pages = [];
|
|
||||||
|
|
||||||
for (const context of this.contexts) {
|
|
||||||
await context.close().catch(() => {});
|
|
||||||
}
|
|
||||||
this.contexts = [];
|
|
||||||
|
|
||||||
if (this.browser) {
|
if (this.browser) {
|
||||||
await this.browser.close().catch(() => {});
|
await this.browser.close().catch(() => {});
|
||||||
this.browser = null;
|
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 = [];
|
||||||
|
this.contexts = [];
|
||||||
|
this.cdpPort = null;
|
||||||
this.activePageIndex = 0;
|
this.activePageIndex = 0;
|
||||||
this.refMap = {};
|
this.refMap = {};
|
||||||
this.lastSnapshot = '';
|
this.lastSnapshot = '';
|
||||||
|
|||||||
+6
-1
@@ -158,7 +158,12 @@ export async function startDaemon(): Promise<void> {
|
|||||||
parseResult.command.action !== 'launch' &&
|
parseResult.command.action !== 'launch' &&
|
||||||
parseResult.command.action !== 'close'
|
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
|
// Handle close command specially
|
||||||
|
|||||||
@@ -461,6 +461,24 @@ describe('parseCommand', () => {
|
|||||||
expect(result.command.headless).toBe(false);
|
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', () => {
|
describe('mouse actions', () => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const launchSchema = baseCommandSchema.extend({
|
|||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
browser: z.enum(['chromium', 'firefox', 'webkit']).optional(),
|
browser: z.enum(['chromium', 'firefox', 'webkit']).optional(),
|
||||||
|
cdpPort: z.number().positive().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const navigateSchema = baseCommandSchema.extend({
|
const navigateSchema = baseCommandSchema.extend({
|
||||||
|
|||||||
@@ -12,12 +12,16 @@ export interface LaunchCommand extends BaseCommand {
|
|||||||
headless?: boolean;
|
headless?: boolean;
|
||||||
viewport?: { width: number; height: number };
|
viewport?: { width: number; height: number };
|
||||||
browser?: 'chromium' | 'firefox' | 'webkit';
|
browser?: 'chromium' | 'firefox' | 'webkit';
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
executablePath?: string;
|
||||||
|
cdpPort?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavigateCommand extends BaseCommand {
|
export interface NavigateCommand extends BaseCommand {
|
||||||
action: 'navigate';
|
action: 'navigate';
|
||||||
url: string;
|
url: string;
|
||||||
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
|
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
|
||||||
|
headers?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClickCommand extends BaseCommand {
|
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({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
include: ['src/**/*.test.ts'],
|
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
||||||
testTimeout: 30000,
|
testTimeout: 30000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user